228 Commits
Author SHA1 Message Date
alu d6b49f0c85 update README 2026-10-27 21:16:04 +00:00
alu b38c381cf0 remove replace for common-lib 2026-10-26 23:15:38 +00:00
alu 33766d1205 use logging middleware from common-lib
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 18s
2026-10-26 18:45:39 +00:00
alu a13331e5a5 minor cleanup
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 16s
2026-10-26 18:23:43 +00:00
alu 40dc935eea fix path for loading icons
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 15s
2026-10-26 05:14:57 +00:00
alu 382d207e0e minor style fix 2026-10-23 22:25:42 +00:00
alu 3eca3da157 use HTTPExecuteHTMLTemplate and HTTPExecuteHTMLFragmentTemplate 2026-10-23 21:56:07 +00:00
alu 4743bcf756 update go mod 2026-10-23 21:33:23 +00:00
alu 73b58a843d re-add logging middlware
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 14s
2026-10-23 21:06:37 +00:00
alu e0ef1b598b update go mod 2026-10-22 21:02:18 +00:00
alu f6164b3096 fix paths to new websrc 2026-10-22 20:57:20 +00:00
alu 5c658db34e use GODEBUG=embedfollowsymlinks=1 to support symlinked web src files
tronnet/workflows: Code Style -- Web Components / style-web (push) Failing after 18s
tronnet/workflows: Static Analysis -- Golang / static-go (push) Failing after 22s
2026-10-22 20:54:23 +00:00
alu 98790dbe3b rewrite using net/http
tronnet/workflows: Static Analysis -- Golang / static-go (push) Failing after 5s
2026-10-22 19:03:37 +00:00
alu db8ed2e830 move all static assets under /websrc/* endpoint
tronnet/workflows: Static Analysis -- Golang / static-go (push) Failing after 8s
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 34s
2026-10-20 23:27:28 +00:00
alu f3aa769fb7 adjust fuzzy alignment weights and normalize against larger of input sizes
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 23s
2026-10-20 22:25:39 +00:00
alu b92aabb052 update ct-templates and vm-isos endpoint paths
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 14s
2026-09-14 18:53:45 +00:00
alu 77f26a3e2c update wfa js with new gzip params 2026-09-09 17:01:09 +00:00
alu 9458840292 cleanup makefile 2026-09-08 22:54:17 +00:00
alu ed4cf064e3 add gzip type to support gziped wfa js
tronnet/workflows: Static Analysis -- Golang / static-go (push) Failing after 9s
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 15s
2026-09-08 22:02:10 +00:00
alu c816d15b1a update wfa.wasm 2026-09-02 20:30:13 +00:00
alu 2fced6bd11 cleanup makefile 2026-09-02 20:29:20 +00:00
alu b2bb8ae69e update WFA-JS
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 16m16s
2026-09-02 18:46:28 +00:00
alu c5067f1696 remove init hook 2026-09-02 16:25:17 +00:00
alu de8f5368a5 remove .htmlvalidateignore 2026-09-02 16:09:47 +00:00
alu 2a6641f03c update to go 1.27 2026-08-25 18:54:20 +00:00
alu a5b20519d4 minor comment cleanup
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 20s
2026-08-19 23:47:17 +00:00
alu e000a90456 update common lib 2026-08-19 21:12:22 +00:00
alu b5c8576ab5 update go 1.26.6 2026-08-17 19:48:16 +00:00
alu e2039a15b1 minor style fix
tronnet/workflows: Code Style -- Web Components / style-web (push) Successful in 14s
2026-08-11 22:59:46 +00:00
alu 492ae56d48 Update README.md 2026-08-10 21:38:50 +00:00
alu 033017864e Merge pull request 'Dashboard v2.0.0' (#5) from v2.0.0 into main
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 44s
tronnet/workflows: Code Style -- Web Components / style-web (push) Failing after 16s
Reviewed-on: #5
2026-08-10 21:10:15 +00:00
alu cbed40d904 fix makefile phony targets 2026-08-03 18:24:18 +00:00
alu 075ea07766 add ingore paths for web component linters 2026-07-31 23:59:07 +00:00
alu 91be26e320 update gitignore, format eslint config 2026-07-31 23:30:32 +00:00
alu ff63d8dc15 prevent error dialog from clearing on closing
tronnet/workflows: Code Style -- Web Components / style-web (push) Failing after 19s
2026-07-31 23:21:09 +00:00
alu ffa58e4218 use replace for web instead of static import
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 25s
2026-07-30 19:04:25 +00:00
alu 613f01456f use central repo for common workflows
tronnet/workflows: Static Analysis -- Golang / lint-check (push) Successful in 13s
tronnet/workflows: Code Style -- Web Components / lint-check (push) Failing after 15s
2026-07-29 23:40:43 +00:00
alu e8fd120ead add static analysis action
Static Analysis -- Golang / lint-check (push) Successful in 18s
Code Style -- Web Components / lint-check (push) Successful in 13s
2026-07-29 22:42:33 +00:00
alu ce1576fbd6 remove global usage for wasm
ProxmoxAAS-Dashboard Code Style Check / lint-check (push) Successful in 16s
2026-07-29 16:13:55 +00:00
alu 0caf27eb7d fix missing autocomplete attributes
ProxmoxAAS-Dashboard Code Style Check / lint-check (push) Failing after 14s
2026-07-29 03:36:59 +00:00
alu 7f5c016e31 add lint workflow, remove node package
ProxmoxAAS-Dashboard Code Style Check / lint-check (push) Failing after 14s
2026-07-29 03:21:56 +00:00
alu 4582d9726d fix error dialog styling 2026-07-28 21:36:51 +00:00
alu 1f672e16e6 remove alert interface and use new error interface 2026-07-28 20:41:01 +00:00
alu 5786b7e1be code cleanup 2026-07-24 22:18:55 +00:00
alu ca386adc26 improve account resource chart creation,
now allows categories to have the same name as another resource without collision issue
2026-07-22 18:17:20 +00:00
alu 7809e6adb1 update go mod, remove unused test make target 2026-07-17 21:35:19 +00:00
alu 159b08a7ca improve makefile 2026-07-15 23:25:54 +00:00
alu 33db3c15b8 cleanup settings logic 2026-07-15 05:03:07 +00:00
alu 526b2a0ff0 remove update-modules npm script 2026-07-07 23:27:14 +00:00
alu ff1f618ad7 update to go 1.26.5 2026-07-07 23:24:46 +00:00
alu 47415fbdd9 update common lib 2026-07-07 03:39:53 +00:00
alu 3646bc1272 update readme 2026-07-06 23:52:25 +00:00
alu 31d097bc19 update wfa-js 2026-07-06 20:47:18 +00:00
alu 0822b9f26c fix missing dist dir make issue 2026-07-06 20:41:00 +00:00
alu 8710da338b add wfa-js as submodule, consolidate build process 2026-07-06 20:37:48 +00:00
alu 415d60d228 update wfa 2026-07-06 18:51:24 +00:00
alu cede3b4b7f add Alert title to alert dialog template 2026-07-01 18:51:33 +00:00
alu 3a7c4cd2ae move unit related constants and functions to common lib 2026-06-26 21:01:51 +00:00
alu 428d5d15a5 fix template formatting 2026-06-26 18:30:08 +00:00
alu 3f2964db55 update go mod 2026-06-17 16:40:10 +00:00
alu 666fd92b30 minor code cleanup 2026-06-15 16:13:42 +00:00
alu 2ef8837a03 cleanup log messaging 2026-06-10 20:17:27 +00:00
alu b58e28ebb5 remove unused Icon struct type 2026-06-10 20:04:22 +00:00
alu 8f72aaf255 reduce inline css usage,
fix additional font inconsistencies,
fix px unit used with 0 values
2026-06-09 23:33:27 +00:00
alu 10ef24e76b move some util definitions and code to common lib 2026-06-08 20:28:39 +00:00
alu 00fa5f3152 fix incorrect display in backups 2026-06-08 18:34:18 +00:00
alu 94233000df fix search button 2026-06-06 23:11:51 +00:00
alu d88a208da5 font consistency fixes 2026-06-05 21:38:51 +00:00
alu 3b1b20b506 template cleanup 2026-06-04 17:58:42 +00:00
alu 66747fa657 update go mod 2026-06-03 17:05:23 +00:00
alu f40638598d move flag parsing to app.go 2026-06-02 18:00:24 +00:00
alu 0082f2f3e5 update go mod 2026-05-31 02:18:56 +00:00
alu 549316ab73 remove temp message notifying of login delay regression 2026-05-30 06:22:27 +00:00
alu fc58861046 add release compile flag for mapping mime types 2026-05-29 16:35:12 +00:00
alu 9b7404c8d6 update go mod, fix get config to use os.Root 2026-05-28 20:05:20 +00:00
alu 08cd4dfaaa add various missing error handling 2026-05-27 18:43:27 +00:00
alu 26a21e6cc7 move config-inactive to common 2026-05-26 22:43:07 +00:00
alu c3fe936e05 initial changes for API v2.0.0:
- added access manager api token to auth object
- update account page to show pool based resource quotas
- update config logic to use pool based resource quotas
- minor improvements and cleanup
2026-05-26 20:28:21 +00:00
alu eb201de26b add prefer-const to eslint rules,
remove empty block in dialog
2026-05-21 22:00:52 +00:00
alu 545061b2eb update sortable js module 2026-05-01 18:57:42 +00:00
alu 0692c1b96c update configs 2026-04-29 21:34:09 +00:00
alu 4f9d19eb39 update proxmoxaas-common-lib 2026-04-27 21:57:02 +00:00
alu c92c4c4e6d update proxmoxaas-common-lib 2026-04-22 16:28:24 +00:00
alu f44df0446e update go to 1.26.2, update go mod 2026-04-22 16:28:04 +00:00
alu 7475ae30b7 update go mod 2026-04-19 18:13:03 +00:00
alu b2820a3051 update eslint to v10 2026-04-19 17:46:12 +00:00
alu 7aadc03fc9 migrate to using proxmoxaas-common-lib types 2026-04-19 00:55:07 +00:00
alu 9606d62e78 removed fabric as submodule 2026-04-19 00:32:43 +00:00
alu 3f754c65b9 update go mod 2026-04-04 22:28:00 +00:00
alu 5788e1b493 properly return error values if a server error occurs 2026-03-28 07:25:59 +00:00
alu 3636878bb7 fix issue where users with larger pve task audit permission could cause a parse error when extracting vmid value from task without a vmid value 2026-03-28 07:25:25 +00:00
alu 6b094690f9 update paas-fabric 2026-03-25 17:42:39 +00:00
alu 3d8e4b416d update go mod 2026-03-25 17:40:56 +00:00
alu 7a5888be82 update go mod 2026-02-27 21:53:19 +00:00
alu d06f62f5e5 update go mod 2026-02-12 06:01:09 +00:00
alu 72b1179fc9 update paas fabric submodule 2025-12-09 00:51:34 +00:00
alu 32c23daf9d update go mod 2025-12-09 00:50:45 +00:00
alu b86be4c749 add a bunch of comments to important code 2025-11-18 19:36:05 +00:00
alu 338ea3342e remove external Sortable js dependency 2025-11-07 22:56:10 +00:00
alu 3b7d3ba01f fix regression in draggable boot order list drag image,
update Sortable.js,
fix mising icons in boot order list
2025-11-07 21:03:34 +00:00
alu 9e1fca8597 fix missing img alt in templates 2025-10-30 05:30:05 +00:00
alu c8ca49b85c cleanup css 2025-10-28 21:33:55 +00:00
alu 2877f7709a fix issues with icon coloring in chrome by switching to img tags 2025-10-28 18:34:42 +00:00
alu 7db0bea35c temporary fix for error dialog 2025-10-16 23:16:14 +00:00
alu ff98eb318e fix linting 2025-10-14 04:50:04 +00:00
alu 05ced39598 fix exact match bug, 2025-10-13 20:56:05 +00:00
alu 4e2b6278d8 fix chrome flicker for smaller screen sizes 2025-10-10 22:14:57 +00:00
alu 75e098b7b4 fix wrong css class definition in instance card to fix chrome flicker issue 2025-10-10 22:12:37 +00:00
alu 8c378a3b49 update go mod 2025-10-10 21:53:35 +00:00
alu 3d5989a946 fix flickering issue with instance-card refresh and instance search 2025-10-10 21:41:10 +00:00
alu 06afdcec37 reimplement instance card using display;contents to have better alignment and fitting to grid for instance card container,
migrate away from using w3-hide to custom hide for breakpoint fixes
2025-10-10 06:35:28 +00:00
alu 2f21b23535 fix instance card action area slightly too small in medium layouts 2025-10-03 03:58:08 +00:00
alu e8dd28b519 update index with wfa update 2025-10-02 22:04:12 +00:00
alu e0c7a53d85 update wfa,
minor style fix
2025-10-02 22:03:23 +00:00
alu 118b7dac53 minor style fix 2025-10-02 17:52:08 +00:00
alu db32f318b9 update go mod 2025-10-02 17:45:40 +00:00
alu c13a4c8539 update wfa 2025-09-30 17:50:36 +00:00
alu 8d490cd336 remove remaining external css links 2025-09-29 22:05:08 +00:00
alu 343c149330 update w3 css,
remove external css link to speedup render times
2025-09-29 21:59:26 +00:00
alu d95a82f248 move flag parsing to main, add debug mime types 2025-09-25 00:27:24 +00:00
alu fc42de2c49 fix bugs with boot order interface 2025-09-25 00:27:00 +00:00
alu 3f723394c4 update go mod 2025-09-23 03:56:07 +00:00
alu e7627b5787 update go mod, update fabric submodule 2025-09-23 03:53:11 +00:00
alu 7732da0642 fix bug in CPU type selector 2025-09-13 21:42:18 +00:00
alu 87c42495ad bump version to 1.0.0 2025-09-07 05:54:39 +00:00
alu 89065254d2 update fabric 2025-09-07 05:44:32 +00:00
alu 08e5f8b392 fix linting 2025-09-07 05:44:22 +00:00
alu 8be935a421 update go mod, update fabric 2025-09-02 20:12:20 +00:00
alu f94dca7e0c update go mod 2025-08-12 22:14:58 +00:00
alu 8905886065 fix favicon viewbox issue 2025-08-11 22:41:43 +00:00
alu df6772c72b add categorization to account resources 2025-08-11 22:40:39 +00:00
alu f3b6c0abf4 update go mod,
update submodules
2025-08-11 22:37:13 +00:00
alu 69fae92313 add mp selector to config 2025-08-06 19:42:33 +00:00
alu a79dd96d2a disable backup when instance is running 2025-08-04 21:22:52 +00:00
alu ee397c48e1 add restore function to backups 2025-07-25 21:49:52 +00:00
alu 33b0a4b5ff add edit, delete, and restore options to backups
update fabric submodule
2025-07-14 20:58:10 +00:00
alu 65c8fbdca8 implement user triggered backups 2025-07-07 20:59:12 +00:00
alu e932165a98 various minor fixes 2025-07-04 05:33:20 +00:00
alu 8c339794b3 move getAPI request interface body to parameter,
move VMPath and FormatNumber methods to common utils
2025-06-30 23:44:28 +00:00
alu a62fc83386 fix viewbox on delete icons 2025-06-25 22:21:17 +00:00
alu 756aef587d fix viewbox on instance action icons 2025-06-25 21:35:32 +00:00
alu ca555a7116 add unknown status to instance,
add svg minimize
2025-06-20 21:37:19 +00:00
alu 85c3ab49fc format index 2025-06-19 20:28:35 +00:00
alu 9ec277ce65 implement ssr dialog for config 2025-06-19 20:23:10 +00:00
alu e41c8d2a07 revert ssr dialog interface 2025-06-18 21:21:00 +00:00
alu 308d133e6e implement ssr modal dialog for index and account 2025-06-13 00:30:54 +00:00
alu 99d58eb250 add fabric as submodule 2025-06-02 19:25:27 +00:00
alu acd6eba520 fix bug in ssr dialog with multiple event listeners, add ssr dialog for instance delete 2025-05-29 18:33:01 +00:00
alu 478ca20451 implement ssr modal dialog form construction for create instances 2025-05-28 20:08:52 +00:00
alu 28c60aecc9 rename html fragments to go.tmpl extension 2025-05-28 20:08:25 +00:00
alu 3d677a46ee fix some accessibility issues with non focusable elements 2025-05-22 18:34:45 +00:00
alu e170d7f93d update go mod 2025-05-20 17:58:36 +00:00
alu 85bd81ef30 readd coloring to resource chart bars 2025-05-20 17:55:15 +00:00
alu 53832b67a2 minor optimizations to instance card config and console actions 2025-05-18 01:12:58 +00:00
alu e6cd1fbb3d fix linting 2025-05-13 18:37:06 +00:00
alu 3f21f3c4a4 Merge pull request 'Fix Sync Issue with Instance Power Action' (#4) from instance-power-sync-fix into main
Reviewed-on: #4
2025-05-13 17:41:28 +00:00
alu 1bcbed6828 minor code formatting to login.go 2025-05-13 17:35:54 +00:00
alu 31bfa79e66 update wfajs module 2025-05-13 17:35:35 +00:00
alu 989f59223a reimplement updating instance to loading when power action is taken 2025-05-13 17:35:11 +00:00
alu 233d4255ba fixes #3 by using /status/current for recently modified instances 2025-05-13 17:33:59 +00:00
alu 2b5c1bbf11 various code cleanup,
add required tag to select template
2025-05-12 20:59:48 +00:00
alu 800ad7cd60 cleanup various unused code,
simplify instance action hiding when node is not online
2025-04-29 17:44:02 +00:00
alu bb0d363298 update readme 2025-04-22 17:13:31 +00:00
alu 8530b50f9a implement SSR for instance config page 2025-04-22 17:09:27 +00:00
alu 099f9c4e42 simplify front end routes,
simplify ssr fragment fetch methods
2025-04-15 17:13:22 +00:00
alu 844cf4dfb9 move instance card icon logic to template 2025-04-14 19:24:49 +00:00
alu 02baa71622 update readme 2025-04-09 00:04:40 +00:00
alu 38849c1c41 update go mod, add verbose to make build 2025-04-08 23:58:54 +00:00
alu 455ed353c6 remove commented code,
remove Name field from select using ID instead
2025-04-07 23:17:23 +00:00
alu 13339cc56d improve auth interface, fix issue in meta 2025-04-02 21:18:29 +00:00
alu bd0387976f rename instance to config,
simplify resource-chart code
2025-04-02 20:39:38 +00:00
alu add58d849e implement server side rendering for account page,
remove chartjs module
2025-04-01 17:34:56 +00:00
alu a58620eacb update go mod 2025-03-31 20:41:35 +00:00
alu 79567ea58e reorganize app files 2025-03-27 22:46:54 +00:00
alu b8ebbf6c3d implement server side auth checking,
implement some account server side rendering
2025-03-25 19:24:25 +00:00
alu 75330e8a59 fix bug preventing opening instance console 2025-03-20 19:01:50 +00:00
alu 6e4f2505a2 fix linting 2025-03-13 21:59:36 +00:00
alu 6fdc647413 cleanup unreferenced metadata from utils,
improve setting settings values
2025-03-13 21:19:56 +00:00
alu f37f89d5c7 cleanup code 2025-03-12 23:40:33 +00:00
alu 59d12d2e99 implement partial SSR of instances 2025-03-12 20:16:51 +00:00
alu 8b508d14cc remove extra print statement 2025-03-06 01:09:01 +00:00
alu 247f602359 convert spaces to tabs 2025-03-06 00:45:12 +00:00
alu 209a8cc071 cleanup go mod 2025-03-04 22:42:02 +00:00
alu 9bd1fc8321 switch to gin, add basic server redering to login 2025-03-04 22:40:22 +00:00
alu 323129185b add module preload for each page 2025-02-27 22:13:37 +00:00
alu 7f7e17e8ee add minification 2025-02-27 00:53:19 +00:00
alu 73df998985 add go sum to gitignore 2025-02-25 22:47:53 +00:00
alu cfceb32134 implement basic web server for dashboard,
use templates to do basic SSR on head and header
2025-02-25 21:35:11 +00:00
alu 84cbe0e45d move web source files to web/,
move dev configs to config/
2025-02-24 20:36:51 +00:00
alu a80492605d add hostpci number to devices 2025-02-20 23:34:51 +00:00
alu e73e494f65 improved various error messages 2025-02-10 20:33:46 +00:00
alu 510da85c02 update disk operations to use scsi,
sata still supported for compatibility
2025-02-07 23:39:36 +00:00
alu ce2e8867f3 fix possible error when instance has no name 2025-01-06 20:34:09 +00:00
alu 2170077522 fix confirm password showing up in VM creation form 2024-12-22 07:02:29 +00:00
alu c57e2fe19f remove admin and user page (to be implemented in another project) 2024-12-22 06:59:01 +00:00
alu 48afad63e7 update wfa.wasm 2024-11-13 23:00:31 +00:00
alu e2706ce731 fix linting 2024-11-07 21:27:33 +00:00
alu 7c53cf5efe update wfa module and index search 2024-11-07 21:23:38 +00:00
alu 68fafc1e37 simplify addResourceLine interface,
add update modules script to npm scripts,
add value to DraggableItem
2024-11-07 07:37:16 +00:00
alu 9b3d9767e1 add user form,
implement some admin handling,
improve addResourceLine
2024-10-28 20:03:54 +00:00
alu e2f9f8e352 start implement of admin page 2024-10-03 21:02:31 +00:00
alu dc2dcf85cd update account to api change 2024-10-02 22:58:30 +00:00
alu 7d64715c30 update config to api change 2024-10-01 18:19:59 +00:00
alu f09ca0507e fix style issues 2024-09-27 20:42:41 +00:00
alu 39822819cc fix issue in input grid style 2024-09-05 16:37:57 +00:00
alu 912cee33c7 move addResourceLine to utils,
localize  page specific resource line values in page script
2024-08-31 12:31:29 +00:00
alu 746136724f implement proper match highlighting in vm search feature 2024-08-25 03:09:41 +00:00
alu 07bf4b0967 update search icon link 2024-08-24 04:45:49 +00:00
alu 4b57c5c8e8 move search icon to common 2024-08-13 19:03:11 +00:00
alu 7ec72a7443 simplify buttons in config/index,
include form.css in login
2024-08-12 17:11:44 +00:00
alu 58007a38b2 readd style to svg for external use 2024-08-09 19:59:03 +00:00
alu d574df8410 remove custom background from select,
move select styles to form.css
2024-08-08 19:50:16 +00:00
alu e1ef7998f6 update wfa.js 2024-08-07 19:54:33 +00:00
alu 3a8d9df103 add password confirm to create new container instance 2024-08-05 20:45:37 +00:00
alu b4fd1278cd add config icons to users/groups 2024-08-02 20:14:37 +00:00
alu c11c3579f3 replace wfa.js with v0.0.2 (minified) 2024-07-30 20:52:27 +00:00
alu 9a6a9e4473 move w3.css to modules,
move InstanceCard to index.js,
move ResourceChart to account.js
2024-07-26 18:34:14 +00:00
alu 4a69c58125 switch img icons to svg to fix issues with user selected themes 2024-07-24 19:32:40 +00:00
alu bb3edcb005 add local chartjs module 2024-07-22 21:36:44 +00:00
alu 77a060db6b fix issues in boot order drag and drop selector 2024-07-17 19:49:13 +00:00
alu 6de16ec656 user and group add icons 2024-07-16 19:25:25 +00:00
alu db06522d15 add admin page,
move requestTicket and deleteAllCookies to login.js,
update to new auth paths
2024-07-16 19:00:30 +00:00
alu 019a3a4455 fix duplicate styles 2024-06-24 18:19:02 +00:00
alu f58abd4d4d move local settings to utils 2024-06-17 21:02:15 +00:00
alu d3281fc27f add color theme option 2024-06-14 06:17:15 +00:00
alu d2cfbdb2a2 add missing tabindex to instance buttons 2024-06-12 21:48:29 +00:00
alu 53d820b685 remove width/height from svg icons 2024-06-10 20:04:05 +00:00
alu bbfc77dc04 Merge pull request 'Add fuzzy search using WFA' (#2) from wfa-fuzzy-search into main
Reviewed-on: #2
2024-06-05 22:19:10 +00:00
170 changed files with 5681 additions and 3271 deletions

No files matched your search

-42
View File
@@ -1,42 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": "standard",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-tabs": [
"error",
{
"allowIndentationTabs": true
}
],
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
],
"brace-style": [
"error",
"stroustrup",
{
"allowSingleLine": false
}
]
}
}
+3 -3
View File
@@ -1,3 +1,3 @@
vars.js
**/package-lock.json
**/node_modules
**/config.json
dist/*
go.sum
+6
View File
@@ -0,0 +1,6 @@
[submodule "proxmoxaas-common-lib"]
path = proxmoxaas-common-lib
url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib
[submodule "WFA-JS"]
path = WFA-JS
url = https://git.tronnet.net/alu/WFA-JS
+26
View File
@@ -0,0 +1,26 @@
.PHONY: test build build-wfa clean clean-wfa clean-web
build: clean ensure-dist build-wfa
@echo "======================== Building Binary ======================="
# resolve symbolic links in web by copying it into dist/web/
GODEBUG=embedfollowsymlinks=1 CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
test:
GODEBUG=embedfollowsymlinks=1 CGO_ENABLED=0 go run .
clean: clean-wfa clean-web
@echo "======================== Cleaning Project ======================"
go clean
rm -rf dist/*
ensure-dist:
mkdir -p dist
clean-web:
build-wfa:
$(MAKE) -C WFA-JS
cp -f WFA-JS/dist/* app/web/modules
clean-wfa:
$(MAKE) clean -C WFA-JS
+60 -22
View File
@@ -9,32 +9,70 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
- Remote console
- Extended account management
## Prerequisites
- Proxmox VE Cluster (v7.0+)
- Reverse proxy server which can proxy the dashboard and API
- FQDN
- Web server to host the ProxmoxAAS Dashboard (ie Apache2)
# Installation
## Notes
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS Dashboard. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `<FQDN>` in some of the configs.
## ProxmoxAAS System Installation Overview
The ProxmoxAAS project is large and is split into multiple components. There are four required components, the Dashboard, API, Fabric, and access-manager-api. The installation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs.
We will assume different hosts for each component which are accessible by unique host names and public URL addresses. Below is a table of references in the setup instructions across all components. You will need to substitute real addresses and hostnames for these in configurations.
| component | internal address | external address | default port |
| --- | --- | --- | --- |
| Proxmox VE API | pve.local | pve.domain.net | 8006 |
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net | 8080 |
| ProxmoxAAS-API | api.local | paas.domain.net/api | 8081 |
| ProxmoxAAS-Fabric | fabric.local | N/A | 8082 |
| access-manager-api | access.local | N/A| 8083 |
## Prerequisites - Dashboard
- Proxmox VE Cluster (v7.0+)
- Reverse proxy server which can proxy the dashboard, API, and Fabric
- FQDN
## Installation - Dashboard
1. Install Apache2 or another HTTP server onto a container or vm, which will be `Dashboard Host`
2. Clone this repo onto `Dashboard Host`, the default location for web root is `/var/www/html/`
4. Configure Apache2 to serve the app at port 80 by adding the file `dashboard.conf` to `/etc/apache2/sites-avaliable/` with at least the following:
```
<VirtualHost *:80>
DocumentRoot /var/www/html/ProxmoxAAS-Dashboard/
</VirtualHost>
```
4. Enable the site by running `a2ensite dashboard`
1. Initialize any host, which will be the `ProxmoxAAS-Dashboard` component host
2. Download `proxmoxaas-dashboard` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
Rename `template.config.json` to `config.json` and modify:
- `listenPort`: port for PAAS-Dashboard to bind and listen on
- `organization`: name of your org which is displayed on the top left corner
- `dashurl`: url for the dashboard, ie. `https://paas.domain.net`
- `apiurl`: url for PAAS-API, ie. `https://paas.domain.net/api`
- `pveurl`: url for the Proxmox endpoint, ie. `https://pve.domain.net`
3. Execute the binary or additionally download `proxmoxaas-dashboard.service` from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases) to run using systemd
After this step, the Dashboard should be available on the `Dashboard Host` at port `80`
## Configuration - Dashboard
1. In `Dashboard Host`, navigate to this repo's root folder
2. Rename `template.vars.js` to `vars.js` and assign the `API` variable with the value of the API's URL. This will likely be `paas.<FQDN>/api`
After this step, the Dashboard should be available on the `ProxmoxAAS-Dashboard` host at the configured `listenPort`
## Installation - API
To install the API, go to [ProxmoxAAS API](https://git.tronnet.net/tronnet/ProxmoxAAS-API). This is required for the app to function. The API installation will also have steps for setting up the reverse proxy server.
To install the API component, go to [ProxmoxAAS-API](https://git.tronnet.net/tronnet/ProxmoxAAS-API). This is required for the app to function.
## Installation - Fabric
To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function.
## Installation - access-manager-api
To install the access-manager-api component, go to [access-manager-api](https://git.tronnet.net/tronnet/access-manager-api). This is required for the app to function.
## Installation - Reverse Proxy
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. A snippet is show for the minimal configuration assuming the default ports.
```
server {
listen 443 ssl;
server_name paas.domain.net;
location / {
proxy_pass http://dashboard.local:8080/;
proxy_redirect default;
}
location /api/ {
proxy_pass http://api.local:8081/api/;
proxy_redirect default;
}
}
```
2. Start nginx with the new configurations
The PAAS suite should be avaliable and fully functional.
Submodule
+1
Submodule WFA-JS added at b0aacd5f9c.
-79
View File
@@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>proxmox - dashboard</title>
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="stylesheet" href="w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/nav.css">
<link rel="stylesheet" href="css/form.css">
<script src="scripts/account.js" type="module"></script>
<script src="scripts/chart.js" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
@media screen and (width >= 1264px){
#resource-container {
display: grid;
grid-template-columns: repeat(auto-fill, calc(100% / 6));
grid-gap: 0;
justify-content: space-between;
}
}
@media screen and (width <= 1264px) and (width >= 680px) {
#resource-container {
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
grid-gap: 0;
justify-content: space-between;
}
}
@media screen and (width <= 680px) {
#resource-container {
display: flex;
flex-direction: column;
gap: 0;
flex-wrap: nowrap;
justify-content: center;
}
}
</style>
</head>
<body>
<header>
<h1>proxmox</h1>
<label for="navtoggle">&#9776;</label>
<input type="checkbox" id="navtoggle">
<nav>
<a href="index.html">Instances</a>
<a href="account.html" aria-current="page">Account</a>
<a href="settings.html">Settings</a>
<a href="login.html">Logout</a>
</nav>
</header>
<main>
<section>
<h2>Account</h2>
<div class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username:</p>
<p id="pool">Pools:</p>
<p id="vmid">VMID Range:</p>
<p id="nodes">Nodes:</p>
</div>
<div class="w3-card w3-padding">
<div class="flex row nowrap">
<h3>Password</h3>
<button class="w3-button w3-margin" id="change-password">Change Password</button>
</div>
</div>
<div class="w3-card w3-padding">
<h3>Cluster Resources</h3>
<div id="resource-container"></div>
</div>
</section>
</main>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"flag"
"fmt"
"log"
"net/http"
"proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes"
"proxmoxaas-dashboard/app/web"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
)
func Run() {
configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
common.Global = common.GetConfig(*configPath)
log.Printf("[INFO] Read in config from %s\n", *configPath)
// setup http router
router := http.NewServeMux()
m := common.InitMinify()
// setup static resources under web (css, images, modules, scripts)
static := common.MinifyStatic(m, web.Static)
router.HandleFunc("GET /websrc/{path...}", func(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
data := static[path]
w.Header().Set("Content-Encoding", data.MimeType.Encoding)
w.Header().Set("Content-Type", data.MimeType.Type)
w.Write([]byte(data.Data))
})
// setup template resources
html := common.MinifyStatic(m, web.Templates)
common.TMPL = common.LoadHTML(html)
// dynamic routes for pages and page fragments
router.HandleFunc("GET /", routes.HandleGETIndex)
router.HandleFunc("GET /index", routes.HandleGETIndex)
router.HandleFunc("GET /index/instances", routes.HandleGETInstancesFragment)
router.HandleFunc("GET /account", routes.HandleGETAccount)
router.HandleFunc("GET /config", routes.HandleGETConfig)
router.HandleFunc("GET /config/volumes", routes.HandleGETConfigVolumesFragment)
router.HandleFunc("GET /config/nets", routes.HandleGETConfigNetsFragment)
router.HandleFunc("GET /config/devices", routes.HandleGETConfigDevicesFragment)
router.HandleFunc("GET /config/boot", routes.HandleGETConfigBootFragment)
router.HandleFunc("GET /backups", routes.HandleGETBackups)
router.HandleFunc("GET /backups/backups", routes.HandleGETBackupsFragment)
router.HandleFunc("GET /login", routes.HandleGETLogin)
router.HandleFunc("GET /settings", routes.HandleGETSettings)
server := paas.LoggingMiddleware(router)
log.Printf("[INFO] starting server listening on 0.0.0.0:%d", common.Global.Port)
log.Fatalf("[ERR ] starting gin router: %s", http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", common.Global.Port), server))
}
+57
View File
@@ -0,0 +1,57 @@
//go:build !release
package common
import (
"io"
"github.com/tdewolff/minify/v2"
)
// defines mime type and associated minifier
type MimeType struct {
Type string
Encoding string
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
}
// debug mime types
var MimeTypes = map[string]MimeType{
"css": {
Type: "text/css",
Minifier: nil,
},
"html": {
Type: "text/html",
Minifier: nil,
},
"tmpl": {
Type: "text/plain",
Minifier: nil,
},
"frag": {
Type: "text/plain",
Minifier: nil,
},
"svg": {
Type: "image/svg+xml",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: nil,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"gz": {
Type: "application/wasm",
Encoding: "gzip",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
+61
View File
@@ -0,0 +1,61 @@
//go:build release
package common
import (
"io"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/svg"
)
// defines mime type and associated minifier
type MimeType struct {
Type string
Encoding string
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
}
// map file extension to mime types
var MimeTypes = map[string]MimeType{
"css": {
Type: "text/css",
Minifier: css.Minify,
},
"html": {
Type: "text/html",
Minifier: html.Minify,
},
"tmpl": {
Type: "text/plain",
Minifier: TemplateMinifier,
},
"frag": {
Type: "text/plain",
Minifier: TemplateMinifier,
},
"svg": {
Type: "image/svg+xml",
Minifier: svg.Minify,
},
"js": {
Type: "application/javascript",
Minifier: js.Minify,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"gz": {
Type: "application/wasm",
Encoding: "gzip",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
+37
View File
@@ -0,0 +1,37 @@
package common
import "html/template"
var Global Config
type Config struct {
Port int `json:"listenPort"`
Organization string `json:"organization"`
DASH string `json:"dashurl"`
PVE string `json:"pveurl"`
API string `json:"apiurl"`
}
// variable for html template root
// generated from LoadHTMLToGin
var TMPL *template.Template
// static served file type containing data and mimetype
type StaticFile struct {
Data string
MimeType MimeType
}
// type used for templated <select>
type Select struct {
ID string
Required bool
Options []Option
}
// type used for templated <option>
type Option struct {
Selected bool
Value string
Display string
}
+282
View File
@@ -0,0 +1,282 @@
package common
import (
"bufio"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"github.com/tdewolff/minify/v2"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
)
// get config file from configPath
func GetConfig(configPath string) Config {
root, err := os.OpenRoot(".")
if err != nil {
log.Fatal("[ERR ] error opening root dir: ", err)
}
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil {
log.Fatal("[ERR ] error opening config file: ", err)
}
var config Config
err = json.Unmarshal(content, &config)
if err != nil {
log.Fatal("[ERR ] error parsing config file: ", err)
}
return config
}
// initialize minifier using the meta types specified
func InitMinify() *minify.M {
m := minify.New()
for _, v := range MimeTypes {
if v.Minifier != nil {
m.AddFunc(v.Type, v.Minifier)
}
}
return m
}
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
minified := make(map[string]StaticFile)
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() {
v, err := files.ReadFile(path)
if err != nil {
log.Fatalf("[ERR ] error parsing template file %s: %s", path, err.Error())
}
x := strings.Split(entry.Name(), ".")
if len(x) >= 2 { // file has extension
mimetype, ok := MimeTypes[x[len(x)-1]]
if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier
min, err := m.String(mimetype.Type, string(v)) // try to minify
if err != nil {
log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
}
minified[path] = StaticFile{
Data: min,
MimeType: mimetype,
}
} else { // if extension is not in MimeTypes and does not have minifier, skip minify
minified[path] = StaticFile{
Data: string(v),
MimeType: mimetype,
}
}
} else { // if the file has no extension, skip minify
mimetype := MimeTypes["*"]
minified[path] = StaticFile{
Data: string(v),
MimeType: mimetype,
}
}
}
return nil
})
if err != nil {
log.Printf("[ERR ] error in MinifyStatic: %s", err)
return nil
} else {
return minified
}
}
func LoadHTML(html map[string]StaticFile) *template.Template {
funcMap := template.FuncMap{
"MapKeys": func(x any, sep string) string {
v := reflect.ValueOf(x)
keys := v.MapKeys()
var s strings.Builder
for i := range keys {
if i != 0 {
s.WriteString(sep)
}
s.WriteString(keys[i].String())
}
return s.String()
},
"Map": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
}
root := template.New("").Funcs(funcMap)
if err := LoadAndAddToRoot(funcMap, root, html); err != nil {
log.Fatal(err)
}
return root
}
func LoadAndAddToRoot(FuncMap template.FuncMap, root *template.Template, html map[string]StaticFile) error {
for name, file := range html {
t := root.New(name).Funcs(FuncMap)
_, err := t.Parse(file.Data)
if err != nil {
return err
}
}
return nil
}
func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string) error {
// remove newlines and tabs
rb := bufio.NewReader(r)
for {
line, err := rb.ReadString('\n')
if err != nil && err != io.EOF {
return err
}
line = strings.Replace(line, "\n", "", -1)
line = strings.Replace(line, "\t", "", -1)
line = strings.Replace(line, " ", "", -1)
if _, errws := io.WriteString(w, line); errws != nil {
return errws
}
if err == io.EOF {
break
}
}
return nil
}
func HandleNonFatalError(w http.ResponseWriter, err error) {
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil {
return nil, 0, err
}
if auth != nil {
for k, v := range GetRequestContextFromCookies(*auth) {
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
}
}
client := &http.Client{}
response, err := client.Do(req)
if err != nil {
return nil, response.StatusCode, err
} else if response.StatusCode != 200 {
return response, response.StatusCode, nil
}
data, err := io.ReadAll(response.Body)
if err != nil {
return nil, response.StatusCode, err
}
err = response.Body.Close()
if err != nil {
return nil, response.StatusCode, err
}
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
case *map[string]any:
err = json.Unmarshal(data, &body)
if err != nil {
return nil, response.StatusCode, err
}
case *[]any:
err = json.Unmarshal(data, &body)
if err != nil {
return nil, response.StatusCode, err
}
default:
}
return response, response.StatusCode, nil
}
func GetAuthFromRequest(r *http.Request) (paas.Auth, error) {
_, errAuth := r.Cookie("auth")
username, errUsername := r.Cookie("username")
token, errToken := r.Cookie("PVEAuthCookie")
csrf, errCSRF := r.Cookie("CSRFPreventionToken")
access, errAccess := r.Cookie("PAASAccessManagerTicket")
if errUsername != nil || errAuth != nil || errToken != nil ||
errCSRF != nil || errAccess != nil {
return paas.Auth{}, fmt.Errorf(
"error occurred getting user cookies: (auth: %v, token: %v, csrf: %v)",
errAuth, errToken, errCSRF,
)
}
return paas.Auth{
Username: username.Value,
Token: token.Value,
CSRF: csrf.Value,
AccessManagerTicket: access.Value,
}, nil
}
func GetInstancePathFromRequest(r *http.Request) (paas.InstancePath, error) {
query := r.URL.Query()
req_node := query.Get("node")
req_type := query.Get("type")
req_vmid := query.Get("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return paas.InstancePath{}, fmt.Errorf(
"request missing required values: (node: %s, type: %s, vmid: %s)",
req_node, req_type, req_vmid,
)
}
vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
if err != nil {
return paas.InstancePath{}, err
}
vm_path := paas.InstancePath{
NodeName: req_node,
InstanceType: paas.InstanceType(req_type),
InstanceID: paas.InstanceID(vmid_int),
}
return vm_path, nil
}
func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
return map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
}
}
+252
View File
@@ -0,0 +1,252 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
"github.com/gerow/go-color"
"github.com/go-viper/mapstructure/v2"
)
type Pool struct {
paas.Pool `mapstructure:",squash"`
ResourceCharts map[string]map[string]any
}
type ResourceChart struct {
Type string
Display bool
Name string
Used int64
Max int64
Avail string
Prefix string
Unit string
ColorHex string
}
var Red = color.RGB{
R: 1,
G: 0,
B: 0,
}
var Green = color.RGB{
R: 0,
G: 1,
B: 0,
}
func HandleGETAccount(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
user, err := GetUserBasic(auth)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
pools, err := GetUserPools(auth)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/account.html", map[string]any{
"global": common.Global,
"page": "account",
"user": user,
"pools": pools,
})
} else {
http.Redirect(w, r, "/login", http.StatusFound) // if user is not authed, redirect user to login page
return
}
}
func GetUserBasic(auth paas.Auth) (paas.User, error) {
user := paas.User{}
body := map[string]any{}
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
if err != nil {
return user, err
}
if code != 200 {
return user, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["user"], &user)
return user, err
}
func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
pools := map[string]Pool{}
// get all pools
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
if err != nil {
return pools, err
}
if code != 200 {
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
if err != nil {
return pools, err
}
// get global config for resource type metadata
body = map[string]any{}
// get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", &auth, &body)
if err != nil {
return pools, err
}
if code != 200 {
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
}
meta := body["resources"].(map[string]any)
// for each pool
for poolname, pool := range pools {
// for each resource in pool data
// create pool charts map
pool.ResourceCharts = make(map[string]map[string]any)
for k, v := range pool.Resources {
m := meta[k].(map[string]any)
t := m["type"].(string)
r := v.(map[string]any)
category := m["category"].(string)
// create a category if it does not already exist
if _, ok := pool.ResourceCharts[category]; !ok {
pool.ResourceCharts[category] = map[string]any{}
}
// depending on type, decode the apool data into the corresponding resource type
switch t {
case "numeric":
n := paas.NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
case "storage":
n := paas.StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
case "list":
n := paas.ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
}
// delete the old entry, only categories should be left at the end of the loop
//delete(pools[poolname].Resources, k)
}
pools[poolname] = pool
}
err = FormatPoolResourceCharts(&pools)
if err != nil {
return pools, err
}
return pools, nil
}
func FormatPoolResourceCharts(pools *map[string]Pool) error {
for poolname, pool := range *pools {
// for each resource category
for categoryname, category := range pool.ResourceCharts {
// for each resource in each category
for resourcename, resource := range category {
// create a resource chart for resource depending on resource type
switch t := resource.(type) {
case paas.NumericResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case paas.StorageResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case paas.ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
avail := fmt.Sprintf("%d", r.Avail)
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: avail, // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = l
}
}
}
}
return nil
}
// interpolate between min and max by normalized (0 - 1) val
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
minhsl := min.ToHSL()
maxhsl := max.ToHSL()
interphsl := color.HSL{
H: (1-val)*minhsl.H + (val)*maxhsl.H,
S: (1-val)*minhsl.S + (val)*maxhsl.S,
L: (1-val)*minhsl.L + (val)*maxhsl.L,
}
return interphsl.ToRGB()
}
+105
View File
@@ -0,0 +1,105 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
"time"
"github.com/go-viper/mapstructure/v2"
)
type InstanceBackup struct {
Volid string `json:"volid"`
Notes string `json:"notes"`
Size int64 `json:"size"`
CTime int64 `json:"ctime"`
SizeFormatted string
TimeFormatted string
}
func HandleGETBackups(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
return
}
config, err := GetInstanceConfig(vm_path, auth) // only used for the VM's name
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/backups.html", map[string]any{
"global": common.Global,
"page": "backups",
"backups": backups,
"config": config,
})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
}
func HandleGETBackupsFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/backups-backups.go.tmpl", map[string]any{
"backups": backups,
})
} else { // return 401
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
body := []any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return backups, err
}
if code != 200 {
return backups, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(body, &backups)
if err != nil {
return backups, err
}
for i := range backups {
size, prefix := paas.FormatNumber(paas.SafeUint64(backups[i].Size), paas.Base1024)
backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
}
return backups, nil
}
+283
View File
@@ -0,0 +1,283 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
"slices"
"sort"
"github.com/go-viper/mapstructure/v2"
)
type InstanceConfig struct {
paas.Instance `mapstructure:",squash"`
// overrides
ProctypeSelect common.Select
}
type GlobalConfig struct {
CPU struct {
Whitelist bool
}
}
type PoolConfig struct {
CPU struct {
Global []paas.Match
Nodes map[string][]paas.Match
}
}
func HandleGETConfig(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
return
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true
}
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/config.html", map[string]any{
"global": common.Global,
"page": "config",
"config": config,
})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
}
func HandleGETConfigVolumesFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-volumes.go.tmpl", map[string]any{
"config": config,
})
} else {
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func HandleGETConfigNetsFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-nets.go.tmpl", map[string]any{
"config": config,
})
} else { // return 401
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func HandleGETConfigDevicesFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-devices.go.tmpl", map[string]any{
"config": config,
})
} else { // return 401
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func HandleGETConfigBootFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(r)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-boot.go.tmpl", map[string]any{
"config": config,
})
} else { // return 401
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return config, err
}
if code != 200 {
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(body, &config)
if err != nil {
return config, err
}
config.Memory = config.Memory / (1024 * 1024) // memory in MiB
config.Swap = config.Swap / (1024 * 1024) // swap in MiB
return config, nil
}
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
cputypes := common.Select{
ID: "proctype",
Required: true,
}
// get global resource config
body := map[string]any{}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
globalConfig := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &globalConfig)
if err != nil {
return cputypes, err
}
// get pool resource config
body = map[string]any{}
path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
if err != nil {
return cputypes, err
}
// use node specific rules if present, otherwise use global rules
var userCPU []paas.Match
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
} else {
userCPU = poolCPUConfig.CPU.Global
}
if globalConfig.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
} else { // cpu is a blacklist
// get the supported cpu types from the node
body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []paas.Match
}{}
err = mapstructure.Decode(body, supported)
if err != nil {
return cputypes, err
}
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c paas.Match) bool {
return c.Name == cpu.Name
})
if !contains {
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
}
}
// sort the options by lexicographical order
sort.Slice(cputypes.Options, func(i, j int) bool {
return cputypes.Options[i].Display < cputypes.Options[j].Display
})
return cputypes, nil
}
+218
View File
@@ -0,0 +1,218 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
"strconv"
"github.com/go-viper/mapstructure/v2"
)
// primary type used in constructing instance cards in index
type InstanceCard struct {
VMID uint
Name string
Type string
Status string
Node string
NodeStatus string
ConfigPath string
ConsolePath string
BackupsPath string
}
// used in constructing instance cards in index
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
}
// used in retriving cluster tasks
type Task struct {
Type string
Node string
User string
ID string
VMID uint
Status string
EndTime uint
}
type InstanceStatus struct {
Status string
}
func HandleGETIndex(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
page := map[string]any{
"global": common.Global,
"page": "index",
"instances": instances,
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/index.html", page)
} else { // return index without populating
http.Redirect(w, r, "/login", http.StatusFound)
return
}
}
func HandleGETInstancesFragment(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/index-instances.go.tmpl", map[string]any{
"instances": instances,
})
} else { // return 401
w.WriteHeader(http.StatusUnauthorized)
return
}
}
func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve resources, then return the error because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
}
instances := map[uint]InstanceCard{}
nodes := map[string]Node{}
// parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range body {
m := v.(map[string]any)
switch m["type"] {
case "node": // if type is node -> parse as Node object
node := Node{}
err := mapstructure.Decode(v, &node)
if err != nil {
return nil, nil, err
}
nodes[node.Node] = node
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
instance := InstanceCard{}
err := mapstructure.Decode(v, &instance)
if err != nil {
return nil, nil, err
}
instances[instance.VMID] = instance
}
}
// once all basic instance and node stuff is parsed, go back and fill in cross referenced data
for vmid, instance := range instances {
// set instance's node status
instance.NodeStatus = nodes[instance.Node].Status
// set instance's config link path
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// set the instance's console link path
switch instance.Type {
case "qemu":
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
case "lxc":
instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
}
// set the instance's backups link path
instance.BackupsPath = fmt.Sprintf("backups?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// save back to instances map
instances[vmid] = instance
}
body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
}
most_recent_task := map[uint]uint{}
expected_states := map[uint]string{}
// iterate through recent user accessible tasks to find the task most recently made on an instance
for _, v := range body {
// parse task as Task object
task := Task{}
err := mapstructure.Decode(v, &task)
if err != nil {
return nil, nil, err
}
// try to get task vmid but continue if it would be an invalid vmid
x, err := strconv.Atoi(task.ID)
// if there was an error converting the task's vmid, skip it
if err != nil {
continue
}
task.VMID = uint(x)
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
continue
} else if _, ok := instances[task.VMID]; !ok { // task does not refer to an instance in user's instances
continue
} else if instances[task.VMID].Node != task.Node { // task does not have the correct node reference (should not happen)
continue
} else if !(task.Type == "qmstart" || task.Type == "qmstop" || task.Type == "vzstart" || task.Type == "vzstop") { // task is not start/stop for qemu or lxc
continue
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
continue
} else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task
switch task.Type {
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
expected_states[task.VMID] = "running"
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
expected_states[task.VMID] = "stopped"
}
}
}
}
// iterate through the instances with recent tasks, refetch their state from a more reliable source
for vmid, expected_state := range expected_states { // for the expected states from recent tasks
if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state
// get /status/current which is updated faster than /cluster/resources
instance := instances[vmid]
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
}
// attempt to decode task status as instance status
status := InstanceStatus{}
err = mapstructure.Decode(body, &status)
if err != nil { // did not successfully decode task status, just skip
continue
}
instance.Status = status.Status
instances[vmid] = instance
}
}
return instances, nodes, nil
}
+69
View File
@@ -0,0 +1,69 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
"github.com/go-viper/mapstructure/v2"
)
// stores each realm's data
type Realm struct {
Default int `json:"default"`
Realm string `json:"realm"`
Comment string `json:"comment"`
}
func GetLoginRealms() ([]Realm, error) {
realms := []Realm{}
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
if err != nil {
return realms, err
}
if code != 200 { // we expect /access/domains to always be avaliable
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
}
for _, v := range body {
v = v.(map[string]any)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
if err != nil {
return realms, err
}
realms = append(realms, realm)
}
return realms, nil
}
func HandleGETLogin(w http.ResponseWriter, r *http.Request) {
realms, err := GetLoginRealms()
if err != nil {
common.HandleNonFatalError(w, err)
return
}
sel := common.Select{
ID: "realm",
Required: true,
}
for _, realm := range realms {
sel.Options = append(sel.Options, common.Option{
Selected: realm.Default != 0,
Value: realm.Realm,
Display: realm.Comment,
})
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/login.html", map[string]any{
"global": common.Global,
"page": "login",
"realms": sel,
})
}
+26
View File
@@ -0,0 +1,26 @@
package routes
import (
"net/http"
"proxmoxaas-dashboard/app/common"
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
)
func HandleGETSettings(w http.ResponseWriter, r *http.Request) {
auth, err := common.GetAuthFromRequest(r)
if err == nil {
_, err := GetUserBasic(auth)
if err != nil {
common.HandleNonFatalError(w, err)
return
}
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/settings.html", map[string]any{
"global": common.Global,
"page": "settings",
})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
}
+101
View File
@@ -0,0 +1,101 @@
input, select, textarea {
background-color: var(--main-input-bg-color);
color: var(--main-text-color);
}
.input-grid {
display: grid;
gap: 0.5em 1em;
align-items: center;
width: 100%;
}
.input-grid * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0.5em;
padding-bottom: 0.5em;
}
.input-grid input {
padding: 0.5em;
}
.input-grid img {
padding: 0;
}
.input-grid .last-item {
text-align: right;
}
legend {
float: left;
width: 100%;
margin: 0;
padding: 0;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
fieldset {
border: 0;
padding: 0;
}
fieldset > *:last-child {
margin-bottom: 0.5em;
}
fieldset > .input-grid {
float: left;
}
body:not(:-moz-handler-blocked) fieldset {
display: table-cell;
}
input[type="checkbox"] {
width: min-content;
margin-left: auto;
margin-right: auto;
}
input[type="radio"] {
position: inherit;
}
.w3-select, select {
padding: 0.5em;
}
.w3-check {
top: 0;
}
/* sibling of input-grid that is not inside another input grid */
:not(.input-grid) .input-grid + * {
display: inline-block;
width: 100%;
margin-top: 0.5em;
}
dialog {
margin: auto;
max-width: calc(min(100% - 16px, 80ch));
color: var(--main-text-color);
}
dialog #prompt {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
dialog button[value="confirm"] {
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
}
dialog button[value="cancel"] {
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
}
+39 -14
View File
@@ -1,15 +1,19 @@
:root {
--nav-bg-color: black;
--nav-text-color: white;
--nav-header-bg-color: #0f0;
--nav-header-text-color: black;
--nav-link-active-text-color: white;
--nav-link-active-bg-color: var(--main-bg-color, #404040);
--nav-transition-speed: 250ms;
}
@media screen and (prefers-color-scheme: light) {
:root {
@media screen and (prefers-color-scheme: dark) {
:root, :root.dark-theme {
--nav-bg-color: black;
--nav-text-color: white;
--nav-header-bg-color: #0f0;
--nav-header-text-color: black;
--nav-link-active-text-color: white;
--nav-link-active-bg-color: var(--main-bg-color, #404040);
}
:root.light-theme {
--nav-bg-color: black;
--nav-text-color: white;
--nav-header-bg-color: #0f0;
@@ -19,6 +23,27 @@
}
}
@media screen and (prefers-color-scheme: light) {
:root, :root.light-theme {
--nav-bg-color: black;
--nav-text-color: white;
--nav-header-bg-color: #0f0;
--nav-header-text-color: black;
--nav-link-active-text-color: black;
--nav-link-active-bg-color: var(--main-bg-color, white);
}
:root.dark-theme {
--nav-bg-color: black;
--nav-text-color: white;
--nav-header-bg-color: #0f0;
--nav-header-text-color: black;
--nav-link-active-text-color: white;
--nav-link-active-bg-color: var(--main-bg-color, #404040);
}
}
header {
display: grid;
align-items: center;
@@ -28,7 +53,7 @@ header {
}
header h1 {
font-size: 18px;
font-size: var(--small-font-size);
margin: 0;
background-color: var(--nav-header-bg-color);
color: var(--nav-header-text-color);
@@ -36,15 +61,15 @@ header h1 {
}
nav {
font-size: var(--small-font-size);
overflow: hidden;
font-size: larger;
width: fit-content;
}
nav a, header h1, label[for="navtoggle"] {
text-align: left;
padding-left: 8px;
padding-right: 8px;
padding-left: 0.5em;
padding-right: 0.5em;
text-decoration: none;
vertical-align: middle;
height: 2em;
@@ -55,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
display: none;
}
@media screen and (width >= 600px){
@media screen and (width >= 601px){
header {
grid-template-columns: auto 1fr;
}
@@ -81,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
}
}
@media screen and (width <= 600px){
@media screen and (width <= 601px){
header {
grid-template-columns: 1fr auto;
}
+216
View File
@@ -0,0 +1,216 @@
:root {
--negative-color: #f00;
--positive-color: #0f0;
--highlight-color: yellow;
--lightbg-text-color: black;
--large-font-size: 32px;
--medium-font-size: 24px;
--small-font-size: 16px;
}
@media screen and (prefers-color-scheme: dark) {
:root, :root.dark-theme {
--main-bg-color: #404040;
--main-text-color: white;
--main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-input-bg-color: #404040;
}
:root.light-theme {
--main-bg-color: white;
--main-text-color: black;
--main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-input-bg-color: white;
}
}
@media screen and (prefers-color-scheme: light) {
:root, :root.light-theme {
--main-bg-color: white;
--main-text-color: black;
--main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-input-bg-color: white;
}
:root.dark-theme {
--main-bg-color: #404040;
--main-text-color: white;
--main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-input-bg-color: #404040;
}
}
*, h1, h2, h3, h4, h5, h6, p {
box-sizing: border-box;
font-family: monospace;
line-height: normal;
margin: 0;
padding: 0;
}
h2 {
font-size: var(--large-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h3 {
font-size: var(--medium-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h4, legend {
font-size: var(--small-font-size);
text-decoration: underline;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
p {
font-size: var(--small-font-size);
}
html {
background-color: var(--main-bg-color);
}
body {
min-height: 100vh;
max-width: 100vw;
display: grid;
grid-template-rows: auto 1fr;
}
main {
max-width: 100vw;
background-color: var(--main-bg-color);
color: var(--main-text-color);
padding: 0 16px 16px;
}
.w3-card {
background-color: var(--main-card-bg-color);
box-shadow: var(--main-card-box-shadow);
}
.w3-card + .w3-card {
margin-top: 16px;
}
img, svg {
height: 1em;
width: 1em;
color: var(--main-text-color)
}
a img {
vertical-align: unset;
}
hr {
border-color: var(--main-text-color);
}
.clickable {
cursor: pointer;
}
.flex {
display: flex;
}
.row {
flex-direction: row;
column-gap: 10px;
align-items: center;
}
.row-reverse {
flex-direction: row-reverse;
row-gap: 10px;
align-items: center;
}
.column {
flex-direction: column;
row-gap: 10px;
align-items: center;
}
.column-reverse {
flex-direction: column-reverse;
row-gap: 10px;
align-items: center;
}
.wrap {
flex-wrap: wrap;
row-gap: 10px;
}
.nowrap {
flex-wrap: nowrap;
}
.hidden {
visibility: hidden;
}
.none {
display: none;
}
.spacer {
min-height: 1em;
}
@media screen and (width >= 440px) {
button .large {
display: block;
}
button .small {
display: none;
}
}
@media screen and (width <= 440px) {
button .large {
display: none;
}
button .small {
display: block;
}
}
/* add hide large class similar to w3-hide-medium and w3-hide-small */
@media screen and (width >= 993px) {
.hide-large {display: none !important;}
}
/* fixes edge case in w3-hide-medium where width between 992 and 993 */
@media screen and (width <= 993px) and (width >= 601px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
}
/* fixes edge case in w3-hide-small when width between 600 and 601 */
@media screen and (width <= 601px) and (width >= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
}
+16
View File
@@ -0,0 +1,16 @@
package web
import (
"embed"
)
//go:embed css/*
//go:embed images/*
//go:embed images/*/*/*
//go:embed modules/*
//go:embed scripts/*
var Static embed.FS
//go:embed html/*
//go:embed templates/*
var Templates embed.FS
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/account.js" type="module"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<link rel="modulepreload" href="websrc/scripts/dialog.js">
<style>
@media screen and (width >= 1264px){
.resource-container {
display: grid;
grid-template-columns: repeat(auto-fill, calc(100% / 6));
grid-gap: 0;
justify-content: space-between;
}
}
@media screen and (width <= 1264px) and (width >= 680px) {
.resource-container {
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
grid-gap: 0;
justify-content: space-between;
}
}
@media screen and (width <= 680px) {
.resource-container {
display: flex;
flex-direction: column;
gap: 0;
flex-wrap: nowrap;
justify-content: center;
}
}
</style>
</head>
<body>
{{template "header" .}}
<main>
<h2>Account</h2>
<section class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username: {{.user.Username.UserID}}@{{.user.Username.Realm}}</p>
<p id="email">Email: {{.user.Mail}}</p>
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
</section>
{{range $poolname, $pool := .pools}}
{{template "pool-resources" $pool}}
{{end}}
</main>
<template id="change-password-dialog">
<link rel="stylesheet" href="websrc/modules/w3.css">
<link rel="stylesheet" href="websrc/css/style.css">
<link rel="stylesheet" href="websrc/css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
Change Password
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" autocomplete="new-password" required>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
</div>
</dialog>
</template>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/backups.js" type="module"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<link rel="modulepreload" href="websrc/scripts/dialog.js">
<style>
</style>
</head>
<body>
{{template "header" .}}
<main>
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
<section class="w3-card w3-padding">
<div class="w3-row" style="border-bottom: 1px solid;">
<p class="w3-col l2 m4 s8">Time</p>
<p class="w3-col l6 m6 hide-small">Notes</p>
<p class="w3-col l2 hide-medium">Size</p>
<p class="w3-col l2 m2 s4">Actions</p>
</div>
<div id="backups-container">
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}
</div>
<div class="w3-container w3-center">
{{template "backups-add-backup" .}}
</div>
</section>
<div class="w3-container w3-center">
<a class="w3-button w3-margin" id="exit" href="index">EXIT</a>
</div>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{{template "boot" .config.Boot}}
+1
View File
@@ -0,0 +1 @@
{{template "devices" .config.Devices}}
+1
View File
@@ -0,0 +1 @@
{{template "nets" .config.Nets}}
+1
View File
@@ -0,0 +1 @@
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/config.js" type="module"></script>
<script src="websrc/scripts/draggable.js" type="module"></script>
<script src="websrc/modules/Sortable.min.js"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<link rel="modulepreload" href="websrc/scripts/dialog.js">
<style>
.input-grid p, .input-grid div {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
white-space: nowrap;
}
</style>
</head>
<body>
{{template "header" .}}
<main>
<section>
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
<form id="config-form">
<fieldset class="w3-card w3-padding">
<legend>Resources</legend>
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
{{if eq .config.Type "VM"}}
{{template "proctype-input" .config.ProctypeSelect}}
{{end}}
{{template "cores-input" .config.Cores}}
{{template "memory-input" .config.Memory}}
{{if eq .config.Type "CT"}}
{{template "swap-input" .config.Swap}}
{{end}}
</div>
</fieldset>
<fieldset class="w3-card w3-padding">
<legend>Volumes</legend>
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
</div>
<div class="w3-container w3-center">
<!--Add Disk Button & Dialog Template-->
{{template "volumes-add-disk" .}}
<!--Add CD Button & Dialog Template-->
{{if eq .config.Type "VM"}}
{{template "volumes-add-cd"}}
{{end}}
</div>
</fieldset>
<fieldset class="w3-card w3-padding">
<legend>Network Interfaces</legend>
<div class="input-grid" id="networks" style="grid-template-columns: auto auto 1fr auto;">
{{template "nets" .config.Nets}}
</div>
<div class="w3-container w3-center">
<!--Add Net Button & Dialog Template-->
{{template "nets-add-net"}}
</div>
</fieldset>
{{if eq .config.Type "VM"}}
<fieldset class="w3-card w3-padding">
<legend>PCIe Devices</legend>
<div class="input-grid" id="devices" style="grid-template-columns: auto auto 1fr auto;">
{{template "devices" .config.Devices}}
</div>
<div class="w3-container w3-center">
<!--Add Device Button & Dialog Template-->
{{template "devices-add-device"}}
</div>
</fieldset>
<fieldset class="w3-card w3-padding">
<legend>Boot Order</legend>
<div id="boot-order">
{{template "boot" .config.Boot}}
</div>
</fieldset>
{{end}}
<div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="exit" type="submit">EXIT</button>
</div>
</form>
</section>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
{{range .instances}}
{{template "instance-card" .}}
{{end}}
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/index.js" type="module"></script>
<script src="websrc/modules/wfa.js" type="module"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<link rel="modulepreload" href="websrc/scripts/dialog.js">
<link rel="modulepreload" href="websrc/scripts/clientsync.js">
<style>
@media screen and (width >= 440px) {
#vm-search {
max-width: calc(100% - 10px - 152px);
}
}
@media screen and (width <= 440px) {
#vm-search {
max-width: calc(100% - 10px - 47px);
}
}
@media screen and (width >= 993px) {
#instance-table {
display: grid;
grid-template-columns: repeat(7, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 993px) and (width >= 601px){
#instance-table {
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 601px) and (width >= 440px){
#instance-table {
display: grid;
grid-template-columns: repeat(4, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 440px) {
#instance-table {
display: grid;
grid-template-columns: repeat(3, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
</style>
</head>
<body>
{{template "header" .}}
<main>
<section>
<h2>Instances</h2>
<div class="w3-card w3-padding">
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
<button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="websrc/images/common/search.svg#symb"></button>
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
</form>
<!--Add Instance Button & Dialog Template-->
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
<span class="large" style="margin: 0;">Create Instance</span>
<img class="small" style="height: 1lh; width: 1lh;" alt="Create Instance" aria-label="Create Instance" src="websrc/images/actions/instance/add.svg#symb">
</button>
<template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
Create New Instance
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" selected-index="-1" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" type="text" required>
<label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required>
<label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
<label for="memory">Memory (MiB)</label>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16" step="1" required>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" autocomplete="new-password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" autocomplete="new-password" required disabled>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
</div>
<div id="instance-table">
<div id="instance-table-header">
<p>ID</p>
<p>Name</p>
<p class="hide-tiny">Type</p>
<p class="hide-small">Status</p>
<p class="hide-medium">Host Name</p>
<p class="hide-medium">Host Status</p>
<p>Actions</p>
</div>
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
<div id="instance-container">
{{range .instances}}
{{template "instance-card" .}}
{{end}}
</div>
</div>
</div>
</section>
</main>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/login.js" type="module"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<link rel="modulepreload" href="websrc/scripts/dialog.js">
</head>
<body>
{{template "header" .}}
<main class="flex" style="justify-content: center; align-items: center;">
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
<h2 class="w3-center">{{.global.Organization}} Login</h2>
<form>
<label for="username"><b>Username</b></label>
<input class="w3-input w3-border" id="username" name="username" type="text" autocomplete="username">
<label for="password"><b>Password</b></label>
<input class="w3-input w3-border" id="password" name="password" type="password" autocomplete="current-password">
<label for="realm">Realm</label>
{{template "select" .realms}}
<div class="w3-center">
<button class="w3-button w3-margin" id="submit" type="submit">LOGIN</button>
</div>
</form>
</div>
</main>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="websrc/scripts/settings.js" type="module"></script>
<link rel="modulepreload" href="websrc/scripts/utils.js">
<style>
label {
display: flex;
height: fit-content;
width: 100%;
align-items: center;
justify-content: left;
column-gap: 0.5em;
}
label + p {
margin-top: 0.5em;
margin-bottom: 1em;
}
p:last-child {
margin-bottom: 0;
}
#save.disabled {
display: none;
}
#save.enabled {
display: unset;
}
</style>
</head>
<body>
{{template "header" .}}
<main>
<h2>Settings</h2>
<form id="settings">
<section class="w3-card w3-padding">
<h3>Synchronization Settings</h3>
<fieldset>
<legend>App Sync Method</legend>
<label><input class="w3-radio" type="radio" id="sync-always" name="sync-scheme" value="always" required>Always Sync</label>
<p>App will always periodically synchronize with Proxmox. High resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-hash" name="sync-scheme" value="hash" required>Check For Sync</label>
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
</fieldset>
<fieldset>
<legend>App Sync Frequency</legend>
<div class="input-grid" style="grid-template-columns: auto auto 1fr;">
<p>Sync every</p><input aria-label="sync rate in seconds" class="w3-input w3-border" type="number" id="sync-rate" name="sync-rate" min="1" required><p>Second(s)</p>
</div>
</fieldset>
</section>
<section class="w3-card w3-padding">
<h3>Search Settings</h3>
<fieldset>
<legend>Instance Search Criteria</legend>
<label><input class="w3-radio" type="radio" id="search-exact" name="search-criteria" value="exact" required>Exact Match</label>
<p>Sorts by exact query match in instance name.</p>
<label><input class="w3-radio" type="radio" id="search-fuzzy" name="search-criteria" value="fuzzy" required>Fuzzy Match</label>
<p>Sorts by best matching to worst matching.</p>
</fieldset>
</section>
<section class="w3-card w3-padding">
<h3>Appearance</h3>
<fieldset>
<legend>Default Theme</legend>
<label>Theme<select class="w3-select w3-border" id="appearance-theme" name="appearance-theme" style="width: fit-content; padding-right: 24px;">
<option value="auto">Auto</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</fieldset>
</section>
<div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin disabled" id="save" type="submit">SAVE</button>
</div>
</form>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
../../common/config.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
@@ -0,0 +1 @@
<svg id="symb" aria-label="config" viewBox="4 2 17 19" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M10 8H5V3m.291 13.357a8 8 0 10.188-8.991" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 332 B

+1
View File
@@ -0,0 +1 @@
../../common/add.svg
+1
View File
@@ -0,0 +1 @@
../../common/config.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-inactive.svg
+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="add cd" viewBox="2.3 2.3 19.4 19.4" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g stroke="currentColor" stroke-width="1.25"><path d="M7 12h5m0 0h5m-5 0V7m0 5v5"/><circle cx="12" cy="12" r="9"/></g></svg>

After

Width:  |  Height:  |  Size: 331 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="create disk" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M25 0H7a7 7 0 00-7 7v18a7 7 0 007 7h18a7 7 0 007-7V7a7 7 0 00-7-7zm5 25a5 5 0 01-5 5H7a5 5 0 01-5-5V7a5 5 0 015-5h18a5 5 0 015 5z"/><path d="M17 6h-2v9H6v2h9v9h2v-9h9v-2h-9V6z"/></g></svg>

After

Width:  |  Height:  |  Size: 412 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="attach disk" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><g fill="#0f0"><path d="M17 12h-2.85a6.25 6.25 0 00-6.21 5H2v2h5.93a6.22 6.22 0 006.22 5H17z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-1"/><path d="M28.23 17A6.25 6.25 0 0022 12h-3v12h3a6.22 6.22 0 006.22-5H34v-2z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-2"/><path fill="none" d="M0 0h36v36H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 467 B

+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-inactive.svg
+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="detach disk" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="red" d="M76.987 235.517H0v40.973h76.987c9.04 33.686 39.694 58.522 76.238 58.522h57.062V176.988h-57.062c-36.543 0-67.206 24.836-76.238 58.529zm435.013 0h-76.995c-9.032-33.693-39.686-58.53-76.23-58.53h-57.062v158.024h57.062c36.537 0 67.19-24.836 76.23-58.522H512v-40.972z"/></svg>

After

Width:  |  Height:  |  Size: 398 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 76 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="move disk" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="resize disk" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="gray"/></svg>

After

Width:  |  Height:  |  Size: 212 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="drag" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M5 10h14m-5 9l-2 2-2-2m4-14l-2-2-2 2m-5 9h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 345 B

+1
View File
@@ -0,0 +1 @@
../../common/add.svg
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 631 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="#808080"/></svg>

After

Width:  |  Height:  |  Size: 540 B

+1
View File
@@ -0,0 +1 @@
../../common/config.svg
+1
View File
@@ -0,0 +1 @@
../../common/config-inactive.svg
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="instance console" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 565 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg>

After

Width:  |  Height:  |  Size: 458 B

+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-inactive.svg
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="4.2 2.4 9 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg>

After

Width:  |  Height:  |  Size: 237 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 350 350"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg>

After

Width:  |  Height:  |  Size: 310 B

+1
View File
@@ -0,0 +1 @@
../../common/add.svg
+1
View File
@@ -0,0 +1 @@
../../common/config.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-inactive.svg
+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="add device" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="cpu" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>

After

Width:  |  Height:  |  Size: 104 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="config" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="delete" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 307 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" aria-label="search" id="symb" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M15.796 15.811L21 21m-3-10.5a7.5 7.5 0 11-15 0 7.5 7.5 0 0115 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 367 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg>

After

Width:  |  Height:  |  Size: 215 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="cpu" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M14.25 8h-4.5A1.752 1.752 0 008 9.75v4.5A1.752 1.752 0 009.75 16h4.5A1.752 1.752 0 0016 14.25v-4.5A1.752 1.752 0 0014.25 8zM14 14h-4v-4h4zm8-5a1 1 0 000-2h-2v-.25A2.752 2.752 0 0017.25 4H17V2a1 1 0 00-2 0v2h-2V2a1 1 0 00-2 0v2H9V2a1 1 0 00-2 0v2h-.25A2.752 2.752 0 004 6.75V7H2a1 1 0 000 2h2v2H2a1 1 0 000 2h2v2H2a1 1 0 000 2h2v.25A2.752 2.752 0 006.75 20H7v2a1 1 0 002 0v-2h2v2a1 1 0 002 0v-2h2v2a1 1 0 002 0v-2h.25A2.752 2.752 0 0020 17.25V17h2a1 1 0 000-2h-2v-2h2a1 1 0 000-2h-2V9zm-4 8.25a.751.751 0 01-.75.75H6.75a.751.751 0 01-.75-.75V6.75A.751.751 0 016.75 6h10.5a.751.751 0 01.75.75z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 813 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" aria-label="pci device" id="symb" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g stroke="currentColor" fill="currentColor"><path d="M480.003 128H48c0-22.056-17.944-40-40-40a8 8 0 000 16c13.234 0 24 10.766 24 24v288a8 8 0 0016 0v-8h16.01C77.238 408 88 397.238 88 384.01V384h392.003C497.646 384 512 369.646 512 352.003V159.997C512 142.354 497.646 128 480.003 128zM496 352.003c0 8.821-7.176 15.997-15.997 15.997H80a8 8 0 00-8 8v8.01c0 4.406-3.584 7.99-7.99 7.99H48V144h432.003c8.821 0 15.997 7.176 15.997 15.997v192.006z"/><path d="M240 192c-22.922 0-43.057 12.12-54.363 30.28a8.026 8.026 0 00-1.737 2.954A63.601 63.601 0 00176 256a63.583 63.583 0 008.264 31.399c.187.398.407.778.656 1.14C196.078 307.354 216.586 320 240 320c35.29 0 64-28.71 64-64a63.583 63.583 0 00-8.264-31.399 8.057 8.057 0 00-.656-1.14C283.922 204.646 263.414 192 240 192zm-48 64c0-4.395.605-8.648 1.717-12.695 3.596 3.178 8.453 6.73 15.035 10.53 6.376 3.681 11.742 6.078 16.208 7.612-2.622 2.061-5.987 4.385-10.208 6.821-8.449 4.878-14.816 7.039-18.36 7.752A47.681 47.681 0 01192 256zm96 0c0 4.103-.52 8.087-1.493 11.891-3.617-3.227-8.542-6.848-15.259-10.726-5.96-3.441-11.036-5.758-15.321-7.298 2.483-1.885 5.564-3.966 9.321-6.135 8.447-4.876 14.816-7.039 18.36-7.752A47.681 47.681 0 01288 256zm-14.052-33.901c-4.562 1.524-10.087 3.96-16.699 7.777-6.252 3.61-10.952 6.997-14.49 10.051-.449-3.245-.759-7.21-.759-11.927 0-9.763 1.314-16.361 2.469-19.785 11.465 1.064 21.775 6.169 29.479 13.884zm-46.329-12.472C226.655 214.344 226 220.354 226 228c0 7.056.557 12.721 1.401 17.26-3.022-1.232-6.59-2.938-10.65-5.282-8.302-4.793-13.33-9.159-15.769-11.883 6.394-8.915 15.757-15.56 26.637-18.468zm-21.57 80.271c4.564-1.524 10.086-3.954 16.702-7.774 6.252-3.61 10.952-6.997 14.49-10.051.449 3.245.759 7.21.759 11.927 0 9.763-1.314 16.361-2.469 19.785-11.466-1.064-21.778-6.17-29.482-13.887zm46.332 12.475C253.345 297.656 254 291.646 254 284c0-7.633-.653-13.635-1.614-18.347 3.066 1.237 6.708 2.97 10.863 5.368 8.764 5.06 13.892 9.652 16.163 12.33-6.4 9.195-15.926 16.054-27.031 19.022z"/><path d="M440 168a8 8 0 000 16c8.822 0 16 7.178 16 16v112c0 8.822-7.178 16-16 16H240c-39.701 0-72-32.299-72-72s32.299-72 72-72h168a8 8 0 000-16H240c-48.523 0-88 39.477-88 88s39.477 88 88 88h200c17.645 0 32-14.355 32-32V200c0-17.645-14.355-32-32-32zm-328 64H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88zm24-88H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88zm24 80H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88z"/><path d="M432 200h-24a8 8 0 000 16h24a8 8 0 000-16zm0 96h-24a8 8 0 000 16h24a8 8 0 000-16zm0-72h-24a8 8 0 000 16h24a8 8 0 000-16zm0 24h-24a8 8 0 000 16h24a8 8 0 000-16zm0 24h-24a8 8 0 000 16h24a8 8 0 000-16z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="cd" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M12 0a12 12 0 1012 12A12 12 0 0012 0zm0 22a10 10 0 1110-10 10 10 0 01-10 10z"/><path d="M12 8a4 4 0 104 4 4 4 0 00-4-4zm0 6a2 2 0 111.73-3A2 2 0 0112 14z"/><path d="M12 18a6 6 0 01-6-6 1 1 0 00-2 0 8 8 0 008 8 1 1 0 000-2zm0-14a1 1 0 000 2 6 6 0 016 6 1 1 0 001 1c2.57 0 .27-9-7-9z"/></g></svg>

After

Width:  |  Height:  |  Size: 509 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="disk" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M22 12H2m3.45-6.89L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11zM6 16h0m4 0h0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 421 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="network" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M9 7V5H7v2H5v4h6V7H9zm-9 9h16V0H0v16zm2-2V2h12v12H2z" fill="currentColor" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 298 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="memory" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M23 9a1 1 0 001-1V5a1 1 0 00-1-1H1a1 1 0 00-1 1v3a1 1 0 001 1 1 1 0 010 2 1 1 0 00-1 1v7a1 1 0 001 1h13a1 1 0 001-1v-3h2v3a1 1 0 001 1h5a1 1 0 001-1v-7a1 1 0 00-1-1 1 1 0 010-2zM2 16h3v2H2zm7 0v2H7v-2zm4 2h-2v-2h2zm6 0v-2h3v2zm3-10.83a3 3 0 000 5.66V14H2v-1.17a3 3 0 000-5.66V6h20z"/><path d="M9 11V9a1 1 0 00-2 0v2a1 1 0 002 0zm4 0V9a1 1 0 00-2 0v2a1 1 0 002 0zm4 0V9a1 1 0 00-2 0v2a1 1 0 002 0z"/></g></svg>

After

Width:  |  Height:  |  Size: 628 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="swap" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M19.841 3.24A10.988 10.988 0 008.54.573l1.266 3.8a7.033 7.033 0 018.809 9.158L17 11.891v7.092h7l-2.407-2.439A11.049 11.049 0 0019.841 3.24zM1 10.942a11.05 11.05 0 0011.013 11.044 11.114 11.114 0 003.521-.575l-1.266-3.8a7.035 7.035 0 01-8.788-9.22L7 9.891V6.034c.021-.02.038-.044.06-.065L7 5.909V2.982H0l2.482 2.449A10.951 10.951 0 001 10.942z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 565 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="active" fill="#0f0" viewBox="0 0 16 16" stroke="#none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>

After

Width:  |  Height:  |  Size: 163 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="inactive" fill="#f00" viewBox="0 0 16 16" stroke="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>

After

Width:  |  Height:  |  Size: 164 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="loading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204.481 204.481"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M162.116 38.31a7.43 7.43 0 00.454-.67c.033-.055.068-.109.1-.164a7.72 7.72 0 00.419-.857c.014-.034.024-.069.038-.104a7.492 7.492 0 00.314-1.008c.068-.288.124-.581.157-.881l.008-.052a7.48 7.48 0 00.043-.796V7.5a7.5 7.5 0 00-7.5-7.5H48.332a7.5 7.5 0 00-7.5 7.5v26.279c0 .269.016.534.043.796l.008.052c.034.3.089.593.157.881.016.069.035.138.053.207.073.273.159.541.261.801.013.034.024.069.038.104.121.296.262.581.419.857.032.056.067.109.1.164.14.232.291.455.454.67.027.035.047.074.074.109l50.255 63.821-50.255 63.821c-.028.035-.047.074-.074.109a7.43 7.43 0 00-.454.67c-.033.055-.068.109-.1.164a7.72 7.72 0 00-.419.857c-.014.034-.024.069-.038.104a7.492 7.492 0 00-.314 1.008 7.308 7.308 0 00-.157.881l-.008.052a7.48 7.48 0 00-.043.796v26.279a7.5 7.5 0 007.5 7.5h107.817a7.5 7.5 0 007.5-7.5v-26.279c0-.269-.016-.534-.043-.796l-.008-.052a7.51 7.51 0 00-.157-.881c-.016-.069-.035-.138-.053-.207a7.492 7.492 0 00-.261-.801c-.013-.034-.024-.069-.038-.104a7.383 7.383 0 00-.419-.857c-.032-.056-.067-.109-.1-.164a7.646 7.646 0 00-.454-.67c-.027-.035-.047-.074-.074-.109l-50.255-63.821 50.255-63.821c.028-.035.047-.074.074-.11zM148.649 15v11.279H55.832V15h92.817zM55.832 189.481v-11.279h92.817v11.279H55.832zm84.866-26.279H63.784l38.457-48.838 38.457 48.838zm-38.457-73.084L63.784 41.279h76.914l-38.457 48.839z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+2
View File
@@ -0,0 +1,2 @@
/*! Sortable 1.15.7 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n<e;n++)o[n]=t[n];return o}function i(t,e,n){return(e=function(t){t=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0===n)return("string"===e?String:Number)(t);e=n.call(t,e||"default");if("object"!=typeof e)return e;throw new TypeError("@@toPrimitive must return a primitive value.")}(t,"string");return"symbol"==typeof t?t:t+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function a(){return(a=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)!{}.hasOwnProperty.call(o,n)||(t[n]=o[n])}return t}).apply(null,arguments)}function r(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?r(Object(n),!0).forEach(function(t){i(e,t,n[t])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):r(Object(n)).forEach(function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))})}return e}function l(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};var n,o={};for(n in t)if({}.hasOwnProperty.call(t,n)){if(-1!==e.indexOf(n))continue;o[n]=t[n]}return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],-1===e.indexOf(n)&&{}.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function e(t){return function(t){if(Array.isArray(t))return o(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return o(t,e);var n={}.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(t){return(s="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),c=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),d=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),h={capture:!1,passive:!1};function f(t,e,n){t.addEventListener(e,n,!y&&h)}function p(t,e,n){t.removeEventListener(e,n,!y&&h)}function g(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBouLine truncated
+17 -1
View File
@@ -1,4 +1,4 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
.w3-danger{color:#fff!important;background-color:#dd0000!important}
.w3-note{color:#000!important;background-color:#fff599!important}
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
.w3-warning{color:#000!important;background-color:#ffb305!important}
.w3-success{color:#fff!important;background-color:#008a00!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
+604
View File
@@ -0,0 +1,604 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();
// wasm setup code
export default function init (path) {
if (typeof global === "undefined") {
var global = globalThis;
}
return new Promise ((res) => {
const go = new Go();
const run = (obj) => {
const wasm = obj.instance;
global.wfa = wasm
go.run(wasm);
res(wasm)
}
if ('instantiateStreaming' in WebAssembly) {
WebAssembly.instantiateStreaming(fetch(path), go.importObject).then(function (obj) {
run(obj)
})
} else {
fetch(path).then(resp =>
resp.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
run(obj)
})
)
}
})
}
BIN
View File
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
import { requestAPI, setAppearance } from "./utils.js";
import { dialog, error } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
async function init () {
setAppearance();
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
}
function handlePasswordChangeButton () {
const template = document.querySelector("#change-password-dialog");
const d = dialog(template, async (result, form) => {
if (result === "confirm") {
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
if (result.status !== 200) {
error(`Attempted to change password but got: ${result.error}`);
}
}
});
const password = d.querySelector("#new-password");
const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
}
password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword);
}
+145
View File
@@ -0,0 +1,145 @@
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
import { error, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
let node;
let type;
let vmid;
async function init () {
setAppearance();
const uriData = getURIData();
node = uriData.node;
type = uriData.type;
vmid = uriData.vmid;
document.querySelector("#backup-add").addEventListener("click", handleBackupAddButton);
}
class BackupCard extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
const editButton = this.shadowRoot.querySelector("#edit-btn");
if (editButton.classList.contains("clickable")) {
editButton.onclick = this.handleEditButton.bind(this);
editButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.editButton();
}
};
}
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
const restoreButton = this.shadowRoot.querySelector("#restore-btn");
if (restoreButton.classList.contains("clickable")) {
restoreButton.onclick = this.handleRestoreButton.bind(this);
restoreButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleRestoreButton();
}
};
}
}
get volid () {
return this.dataset.volid;
}
async handleEditButton () {
const template = this.shadowRoot.querySelector("#edit-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid,
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
if (result.status !== 200) {
error(`Attempted to edit backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
async handleDeleteButton () {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, _form) => {
if (result === "confirm") {
const body = {
volid: this.volid
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
if (result.status !== 200) {
error(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
async handleRestoreButton () {
const template = this.shadowRoot.querySelector("#restore-dialog");
dialog(template, async (result, _form) => {
if (result === "confirm") {
const body = {
volid: this.volid
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
if (result.status !== 200) {
error(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
}
customElements.define("backup-card", BackupCard);
async function refreshBackups () {
let backups = await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (backups.status !== 200) {
error("Error fetching backups.");
}
else {
backups = backups.data;
const container = document.querySelector("#backups-container");
container.setHTMLUnsafe(backups);
}
}
async function handleBackupAddButton () {
const template = document.querySelector("#create-backup-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
if (result.status !== 200) {
error(`Attempted to create backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
@@ -1,26 +1,18 @@
import { requestAPI } from "./utils.js";
import { API } from "../vars.js";
import { getSetting, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
export async function setupClientSync (callback) {
let scheme = localStorage.getItem("sync-scheme");
let rate = Number(localStorage.getItem("sync-rate"));
if (!scheme) {
scheme = "always";
localStorage.setItem("sync-scheme", "always");
const scheme = getSetting("sync-scheme");
const rate = getSetting("sync-rate");
if (scheme === "never") {
return;
}
if (!rate) {
rate = "5";
localStorage.setItem("sync-rate", "5");
}
if (scheme === "always") {
callback();
else if (scheme === "always") {
window.setInterval(callback, rate * 1000);
}
else if (scheme === "hash") {
const newHash = (await requestAPI("/sync/hash")).data;
localStorage.setItem("sync-current-hash", newHash);
callback();
window.setInterval(async () => {
const newHash = (await requestAPI("/sync/hash")).data;
if (localStorage.getItem("sync-current-hash") !== newHash) {
@@ -30,9 +22,8 @@ export async function setupClientSync (callback) {
}, rate * 1000);
}
else if (scheme === "interrupt") {
callback();
const socket = new WebSocket(`wss://${API.replace("https://", "")}/sync/interrupt`);
socket.addEventListener("open", (event) => {
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
socket.addEventListener("open", (_event) => {
socket.send(`rate ${rate}`);
});
socket.addEventListener("message", (event) => {
@@ -41,12 +32,12 @@ export async function setupClientSync (callback) {
callback();
}
else {
console.error("clientsync: recieved unexpected message from server, closing socket.");
error("clientsync: recieved unexpected message from server, closing socket.");
socket.close();
}
});
}
else {
console.error(`clientsync: unsupported scheme ${scheme} selected.`);
error(`clientsync: unsupported scheme ${scheme} selected.`);
}
}
+479
View File
@@ -0,0 +1,479 @@
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash, getCookie } from "./utils.js";
import { error, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
let node;
let type;
let vmid;
async function init () {
setAppearance();
const uriData = getURIData();
node = uriData.node;
type = uriData.type;
vmid = uriData.vmid;
initVolumes();
initNetworks();
initDevices();
document.querySelector("#config-form").addEventListener("submit", handleFormExit);
}
class VolumeAction extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "move") {
this.addEventListener("click", this.handleDiskMove);
}
else if (this.dataset.type === "resize") {
this.addEventListener("click", this.handleDiskResize);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleDiskDelete);
}
else if (this.dataset.type === "attach") {
this.addEventListener("click", this.handleDiskAttach);
}
else if (this.dataset.type === "detach") {
this.addEventListener("click", this.handleDiskDetach);
}
}
async setStatusLoading () {
const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
setIconSrc(icon, "websrc/images/status/loading.svg");
}
async handleDiskDetach () {
const disk = this.dataset.volume;
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
if (result.status !== 200) {
error(`Attempted to detach ${disk} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
}
async handleDiskAttach () {
dialog(this.template, async (result, form) => {
if (result === "confirm") {
const device = form.get("device");
this.setStatusLoading();
const body = {
source: this.dataset.volume.replace("unused", ""),
mp: form.get("mp")
};
const prefix = type === "qemu" ? "scsi" : "mp";
const disk = `${prefix}${device}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body);
if (result.status !== 200) {
error(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
}
async handleDiskResize () {
dialog(this.template, async (result, form) => {
if (result === "confirm") {
const disk = this.dataset.volume;
this.setStatusLoading();
const body = {
size: form.get("size-increment")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body);
if (result.status !== 200) {
error(`Attempted to resize ${disk} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
}
async handleDiskMove () {
const d = dialog(this.template, async (result, form) => {
if (result === "confirm") {
const disk = this.dataset.volume;
this.setStatusLoading();
const body = {
storage: form.get("storage-select"),
delete: form.get("delete-check") === "on" ? "1" : "0"
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body);
if (result.status !== 200) {
error(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
}
async handleDiskDelete () {
const disk = this.dataset.volume;
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${disk} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
}
}
customElements.define("volume-action", VolumeAction);
async function initVolumes () {
document.querySelector("#disk-add").addEventListener("click", handleDiskAdd);
if (type === "qemu") {
document.querySelector("#cd-add").addEventListener("click", handleCDAdd);
}
}
async function refreshVolumes () {
let volumes = await requestDash(`/config/volumes?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (volumes.status !== 200) {
error("Error fetching instance volumes.");
}
else {
volumes = volumes.data;
const container = document.querySelector("#volumes");
container.setHTMLUnsafe(volumes);
}
initVolumes();
}
async function handleDiskAdd () {
const template = document.querySelector("#add-disk-dialog");
const d = dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
storage: form.get("storage-select"),
size: form.get("size")
};
const id = form.get("device");
const prefix = type === "qemu" ? "scsi" : "mp";
const disk = `${prefix}${id}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
if (result.status !== 200) {
error(`Attempted to create ${disk} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
}
});
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
}
async function handleCDAdd () {
const template = document.querySelector("#add-cd-dialog");
const d = dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
iso: form.get("iso-select")
};
const disk = `ide${form.get("device")}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
if (result.status !== 200) {
error(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
}
refreshVolumes();
refreshBoot();
}
});
const isos = await requestAPI(`/access/users/${getCookie("username")}/vm-isos`, "GET");
const select = d.querySelector("#iso-select");
for (const iso of isos.data) {
select.add(new Option(iso.name, iso.volid));
}
select.selectedIndex = -1;
}
class NetworkAction extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") {
this.addEventListener("click", this.handleNetworkConfig);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleNetworkDelete);
}
}
async setStatusLoading () {
const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
setIconSrc(icon, "websrc/images/status/loading.svg");
}
async handleNetworkConfig () {
const netDetails = this.dataset.value;
const netID = this.dataset.network;
const d = dialog(this.template, async (result, form) => {
if (result === "confirm") {
this.setStatusLoading();
const body = {
rate: form.get("rate")
};
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/modify`, "POST", body);
if (result.status !== 200) {
error(`Attempted to change ${net} but got: ${result.error}`);
}
refreshNetworks();
refreshBoot();
}
});
d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0];
}
async handleNetworkDelete () {
const netID = this.dataset.network;
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "websrc/images/status/loading.svg");
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${net} but got: ${result.error}`);
}
refreshNetworks();
refreshBoot();
}
});
}
}
customElements.define("network-action", NetworkAction);
async function initNetworks () {
document.querySelector("#network-add").addEventListener("click", handleNetworkAdd);
}
async function refreshNetworks () {
let nets = await requestDash(`/config/nets?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (nets.status !== 200) {
error("Error fetching instance nets.");
}
else {
nets = nets.data;
const container = document.querySelector("#networks");
container.setHTMLUnsafe(nets);
}
initNetworks();
}
async function handleNetworkAdd () {
const template = document.querySelector("#add-net-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
rate: form.get("rate")
};
if (type === "lxc") {
body.name = form.get("name");
}
const id = form.get("netid");
const net = `net${id}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/create`, "POST", body);
if (result.status !== 200) {
error(`Attempted to create ${net} but got: ${result.error}`);
}
refreshNetworks();
refreshBoot();
}
});
}
class DeviceAction extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") {
this.addEventListener("click", this.handleDeviceConfig);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleDeviceDelete);
}
}
async setStatusLoading () {
const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
setIconSrc(icon, "websrc/images/status/loading.svg");
}
async handleDeviceConfig () {
const deviceID = this.dataset.device;
const deviceDetails = this.dataset.value;
const deviceName = this.dataset.name;
const d = dialog(this.template, async (result, form) => {
if (result === "confirm") {
this.setStatusLoading();
const body = {
device: form.get("device"),
pcie: form.get("pcie") ? 1 : 0
};
const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/modify`, "POST", body);
if (result.status !== 200) {
error(`Attempted to add ${device} but got: ${result.error}`);
}
refreshDevices();
}
});
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
}
async handleDeviceDelete () {
const deviceID = this.dataset.device;
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${device} but got: ${result.error}`);
}
refreshDevices();
}
});
}
}
customElements.define("device-action", DeviceAction);
async function initDevices () {
if (type === "qemu") {
document.querySelector("#device-add").addEventListener("click", handleDeviceAdd);
}
}
async function refreshDevices () {
let devices = await requestDash(`/config/devices?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (devices.status !== 200) {
error("Error fetching instance devices.");
}
else {
devices = devices.data;
const container = document.querySelector("#devices");
container.setHTMLUnsafe(devices);
}
initDevices();
}
async function handleDeviceAdd () {
const template = document.querySelector("#add-device-dialog");
const d = dialog(template, async (result, form) => {
if (result === "confirm") {
const hostpci = form.get("hostpci");
const body = {
device: form.get("device"),
pcie: form.get("pcie") ? 1 : 0
};
const deviceID = `hostpci${hostpci}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body);
if (result.status !== 200) {
error(`Attempted to add ${body.device} but got: ${result.error}`);
}
refreshDevices();
}
});
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = true;
}
async function refreshBoot () {
let boot = await requestDash(`/config/boot?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (boot.status !== 200) {
error("Error fetching instance boot order.");
}
else if (type === "qemu") {
boot = boot.data;
const container = document.querySelector("#boot-order");
container.setHTMLUnsafe(boot);
}
}
async function handleFormExit (event) {
event.preventDefault();
const body = {
cores: document.querySelector("#cores").value,
memory: document.querySelector("#ram").value
};
if (type === "lxc") {
body.swap = document.querySelector("#swap").value;
}
else if (type === "qemu") {
body.proctype = document.querySelector("#proctype").value;
body.boot = document.querySelector("#enabled").value;
}
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/resources`, "POST", body);
if (result.status === 200) {
goToPage("index");
}
else {
error(`Attempted to set basic resources but got: ${result.error}`);
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Spawn modal dialog from template node. Assumes the following structure:
* <template>
* <dialog>
* <p id="prompt"></p>
* <div id="body">
* <form id="form"> ... </form>
* </div>
* <div id="controls">
* <button value="..." form="form"
* <button value="..." form="form"
* ...
* </div>
* </dialog>
* </template>
* Where prompt is the modal dialog's prompt or header,
* body contains an optional form or other information,
* and controls contains a series of buttons which controls the form
*/
export function dialog (template, onclose = async (_result, _form) => { }) {
const dialog = template.content.querySelector("dialog").cloneNode(true);
document.body.append(dialog);
dialog.addEventListener("close", async () => {
const formElem = dialog.querySelector("form");
const formData = formElem ? new FormData(formElem) : null;
await onclose(dialog.returnValue, formData);
formElem.reset();
dialog.close();
dialog.parentElement.removeChild(dialog);
});
if (!dialog.querySelector("form")) {
for (const control of dialog.querySelector("#controls").childNodes) {
control.addEventListener("click", async (e) => {
e.preventDefault();
dialog.close(e.target.value);
});
}
}
document.body.append(dialog);
dialog.showModal();
return dialog;
}
class ErrorDialog extends HTMLElement {
shadowRoot = null;
dialog = null;
errors = null;
constructor () {
super();
this.shadowRoot = this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="websrc/modules/w3.css">
<link rel="stylesheet" href="websrc/css/style.css">
<link rel="stylesheet" href="websrc/css/form.css">
<style>
#errors {
margin-bottom: 0;
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
padding-left: 12px;
padding-right: 12px;
}
#errors * {
margin: 0;
width: 100%;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt">Error</p>
<div id="errors" class="flex column"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
</div>
</form>
</dialog>
`;
this.dialog = this.shadowRoot.querySelector("dialog");
this.errors = this.shadowRoot.querySelector("#errors");
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
control.addEventListener("click", async (e) => {
e.preventDefault();
this.dialog.close(e.target.value);
});
}
this.dialog.addEventListener("close", () => {
if (this.dialog.returnValue === "copy") {
let errors = "";
for (const error of this.errors.childNodes) {
errors += `${error.innerText}\n`;
}
navigator.clipboard.writeText(errors);
}
this.dialog.close();
});
}
appendError (error) {
error = `${(new Date()).toUTCString()}: ${error}`;
const p = document.createElement("p");
p.innerText = error;
this.errors.appendChild(p);
}
showModal () {
this.dialog.showModal();
}
}
customElements.define("error-dialog", ErrorDialog);
export function error (message) {
let dialog = document.querySelector("error-dialog");
if (dialog === null) {
dialog = document.createElement("error-dialog");
document.body.append(dialog);
dialog.appendError(message);
dialog.showModal();
}
else {
dialog.appendError(message);
dialog.showModal();
}
return dialog;
}
+61
View File
@@ -0,0 +1,61 @@
const blank = document.createElement("img");
blank.src = "websrc/images/common/blank.png"; // for whatever reason an svg does NOT work here
class DraggableContainer extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.content = this.shadowRoot.querySelector("#wrapper");
window.Sortable.create(this.content, {
group: this.dataset.group,
ghostClass: "ghost",
setData: function (dataTransfer, _dragEl) {
dataTransfer.setDragImage(blank, 0, 0);
}
});
}
append (newNode) {
this.content.appendChild(newNode, this.bottom);
}
insertBefore (newNode, referenceNode) {
this.content.insertBefore(newNode, referenceNode);
}
querySelector (query) {
return this.content.querySelector(query);
}
hasChildNodes (query) {
return this.querySelector(query) !== null;
}
removeChild (node) {
if (node && this.content.contains(node)) {
this.content.removeChild(node);
return true;
}
else {
return false;
}
}
set value (value) {}
get value () {
const value = [];
this.content.querySelectorAll(".draggable-item").forEach((element) => {
if (element.dataset.value) {
value.push(element.dataset.value);
}
});
return value;
}
}
customElements.define("draggable-container", DraggableContainer);
+438
View File
@@ -0,0 +1,438 @@
import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt, getCookie } from "./utils.js";
import { dialog, error } from "./dialog.js";
import { setupClientSync } from "./clientsync.js";
import wfaInit from "../modules/wfa.js";
window.addEventListener("DOMContentLoaded", init);
var wfa;
async function init () {
setAppearance();
wfa = await wfaInit("websrc/modules/wfa.wasm.gz");
initInstances();
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
document.querySelector("#vm-search").addEventListener("input", sortInstances);
setupClientSync(refreshInstances);
}
class InstanceCard extends HTMLElement {
actionLock = false;
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.actionLock = false;
}
get type () {
return this.dataset.type;
}
set type (type) {
this.dataset.type = type;
}
get status () {
return this.dataset.status;
}
set status (status) {
this.dataset.status = status;
}
get vmid () {
return this.dataset.vmid;
}
set vmid (vmid) {
this.dataset.vmid = vmid;
}
get name () {
return this.dataset.name;
}
set name (name) {
this.dataset.name = name;
}
get node () {
return {
name: this.dataset.node,
status: this.dataset.nodestatus
};
}
set node (node) {
this.dataset.node = node.name;
this.dataset.nodetsatus = node.status;
}
set searchQueryResult (result) {
this.dataset.searchqueryresult = JSON.stringify(result);
}
get searchQueryResult () {
return JSON.parse(!this.dataset.searchqueryresult ? "{}" : this.dataset.searchqueryresult);
}
update () {
const nameParagraph = this.shadowRoot.querySelector("#instance-name");
nameParagraph.innerText = "";
if (this.searchQueryResult.alignment) {
let i = 0; // name index
let c = 0; // alignment index
const alignment = this.searchQueryResult.alignment;
while (i < this.name.length && c < alignment.length) {
if (alignment[c] === "M") {
const part = document.createElement("span");
part.innerText = this.name[i];
part.style = "color: var(--lightbg-text-color); background-color: var(--highlight-color);";
nameParagraph.append(part);
i++;
c++;
}
else if (alignment[c] === "I") {
const part = document.createElement("span");
part.innerText = this.name[i];
nameParagraph.append(part);
i++;
c++;
}
else if (alignment[c] === "D") {
c++;
}
else if (alignment[c] === "X") {
const part = document.createElement("span");
part.innerText = this.name[i];
nameParagraph.append(part);
i++;
c++;
}
}
}
else {
nameParagraph.innerHTML = this.name ? this.name : "&nbsp;";
}
const powerButton = this.shadowRoot.querySelector("#power-btn");
if (powerButton !== null) {
if (powerButton.classList.contains("clickable")) {
powerButton.onclick = this.handlePowerButton.bind(this);
powerButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handlePowerButton();
}
};
}
}
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton !== null) {
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
}
}
setStatusLoading () {
this.status = "loading";
const statusicon = this.shadowRoot.querySelector("#status");
const powerbtn = this.shadowRoot.querySelector("#power-btn");
setIconSrc(statusicon, "websrc/images/status/loading.svg");
setIconAlt(statusicon, "websrc/instance is loading");
setIconSrc(powerbtn, "websrc/images/status/loading.svg");
setIconAlt(powerbtn, "");
}
async handlePowerButton () {
if (!this.actionLock) {
const template = this.shadowRoot.querySelector("#power-dialog");
dialog(template, async (result, _form) => {
if (result === "confirm") {
this.actionLock = true;
const targetAction = this.status === "running" ? "stop" : "start";
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
this.setStatusLoading();
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
while (true) {
const taskStatus = await requestPVE(`/nodes/${this.node.name}/tasks/${result.data}/status`, "GET");
if (taskStatus.data.status === "stopped" && taskStatus.data.exitstatus === "OK") { // task stopped and was successful
break;
}
else if (taskStatus.data.status === "stopped") { // task stopped but was not successful
error(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`);
break;
}
else { // task has not stopped
await waitFor(1000);
}
}
this.actionLock = false;
refreshInstances();
}
});
}
}
handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, _form) => {
if (result === "confirm") {
this.actionLock = true;
const action = {};
action.purge = 1;
action["destroy-unreferenced-disks"] = 1;
const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${this.vmid} but got: ${result.error}`);
}
this.actionLock = false;
refreshInstances();
}
});
}
}
}
customElements.define("instance-card", InstanceCard);
async function refreshInstances () {
let instances = await requestDash("/index/instances", "GET");
if (instances.status !== 200) {
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
}
else {
instances = instances.data;
const container = document.querySelector("#instance-container");
container.setHTMLUnsafe(instances);
sortInstances();
}
}
function initInstances () {
const container = document.querySelector("#instance-container");
let instances = container.children;
instances = [].slice.call(instances);
for (let i = 0; i < instances.length; i++) {
instances[i].update();
}
}
function sortInstances () {
const searchCriteria = getSetting("search-criteria");
const searchQuery = document.querySelector("#search").value || null;
let criteria;
if (!searchQuery) {
criteria = (item, _query = null) => {
return { score: item.vmid, alignment: null };
};
}
else if (searchCriteria === "exact") {
criteria = (item, query) => {
const substrInc = item.includes(query);
if (substrInc) {
const substrStartIndex = item.indexOf(query);
const queryLength = query.length;
const remaining = item.length - substrInc - queryLength + 1;
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
return { score: -1, alignment };
}
else {
const alignment = `${"X".repeat(item.length)}`;
return { score: 0, alignment };
}
};
}
else if (searchCriteria === "fuzzy") {
const penalties = {
m: 0,
x: 2,
o: 3,
e: 1
};
criteria = (item, query) => {
// lower is better
const { score, CIGAR } = wfa.wfAlign(query, item, penalties, true);
const alignment = wfa.DecodeCIGAR(CIGAR);
const maxlength = Math.max(item.length, query.length);
return { score: score / maxlength, alignment };
};
}
const container = document.querySelector("#instance-container");
let instances = container.children;
instances = [].slice.call(instances);
for (let i = 0; i < instances.length; i++) {
if (!instances[i].dataset.name) { // if the instance has no name, assume its just empty string
instances[i].dataset.name = "";
}
const { score, alignment } = criteria(instances[i].dataset.name.toLowerCase(), searchQuery ? searchQuery.toLowerCase() : "");
instances[i].searchQueryResult = { score, alignment };
}
const sortCriteria = (a, b) => {
const aScore = a.searchQueryResult.score;
const bScore = b.searchQueryResult.score;
if (aScore === bScore) {
return a.vmid > b.vmid ? 1 : -1;
}
else {
return aScore - bScore;
}
};
instances.sort(sortCriteria);
for (let i = 0; i < instances.length; i++) {
container.appendChild(instances[i]);
instances[i].update();
}
}
async function handleInstanceAddButton () {
const template = document.querySelector("#create-instance-dialog");
const d = dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
name: form.get("name"),
cores: form.get("cores"),
memory: form.get("memory"),
pool: form.get("pool")
};
if (form.get("type") === "lxc") {
body.swap = form.get("swap");
body.password = form.get("password");
body.ostemplate = form.get("template-image");
body.rootfslocation = form.get("rootfs-storage");
body.rootfssize = form.get("rootfs-size");
}
const node = form.get("node");
const type = form.get("type");
const vmid = form.get("vmid");
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/create`, "POST", body);
if (result.status === 200) {
refreshInstances();
}
else {
error(`Attempted to create new instance ${vmid} but got: ${result.error}`);
refreshInstances();
}
}
});
// setup type select
const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1;
// on type change, reveal or hide the container specific section
typeSelect.addEventListener("change", () => {
if (typeSelect.value === "qemu") {
d.querySelectorAll(".container-specific").forEach((element) => {
element.classList.add("none");
element.disabled = true;
});
}
else {
d.querySelectorAll(".container-specific").forEach((element) => {
element.classList.remove("none");
element.disabled = false;
});
}
});
d.querySelectorAll(".container-specific").forEach((element) => {
element.classList.add("none");
element.disabled = true;
});
// setup pool select
const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
// add user pools to selector
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
poolSelect.addEventListener("change", async () => {
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
const nodeSelect = d.querySelector("#node");
nodeSelect.innerHTML = "";
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
const allowedNodes = Object.keys(pool["nodes-allowed"]);
clusterNodes.forEach((element) => {
if (element.status === "online" && allowedNodes.includes(element.node)) {
nodeSelect.add(new Option(element.node));
}
});
nodeSelect.selectedIndex = -1;
// set vmid min/max
d.querySelector("#vmid").min = pool["vmid-allowed"].min;
d.querySelector("#vmid").max = pool["vmid-allowed"].max;
});
// setup node select
const nodeSelect = d.querySelector("#node");
nodeSelect.selectedIndex = -1;
// on node change, get the available storages and repopulate the storage selector
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value;
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
rootfsStorage.innerHTML = "";
storage.forEach((element) => {
if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage));
}
});
rootfsStorage.selectedIndex = -1;
});
// setup root dir select
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
const rootfsContent = "rootdir";
// setup templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector
const templates = await requestAPI(`/access/users/${getCookie("username")}/ct-templates`, "GET");
for (const template of templates.data) {
templateImage.append(new Option(template.name, template.volid));
}
templateImage.selectedIndex = -1;
// setup custom password checker for containers
const password = d.querySelector("#password");
const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
}
password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword);
d.showModal();
}
+17 -16
View File
@@ -1,20 +1,13 @@
import { requestTicket, goToPage, deleteAllCookies, requestPVE, setTitleAndHeader } from "./utils.js";
import { alert } from "./dialog.js";
import { goToPage, setAppearance, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
async function init () {
setTitleAndHeader();
await deleteAllCookies();
setAppearance();
const formSubmitButton = document.querySelector("#submit");
const realms = await requestPVE("/access/domains", "GET");
const realmSelect = document.querySelector("#realm");
realms.data.forEach((element) => {
realmSelect.add(new Option(element.comment, element.realm));
if ("default" in element && element.default === 1) {
realmSelect.value = element.realm;
}
});
formSubmitButton.addEventListener("click", async (e) => {
e.preventDefault();
const form = document.querySelector("form");
@@ -24,20 +17,28 @@ async function init () {
const ticket = await requestTicket(formData.get("username"), formData.get("password"), formData.get("realm"));
if (ticket.status === 200) {
formSubmitButton.innerText = "LOGIN";
goToPage("index.html");
goToPage("index");
}
else if (ticket.status === 401) {
alert("Authenticaton failed.");
error("Authenticaton failed.");
formSubmitButton.innerText = "LOGIN";
}
else if (ticket.status === 408) {
alert("Network error.");
error("Network error.");
formSubmitButton.innerText = "LOGIN";
}
else {
alert("An error occured.");
error(`An error occured: ${JSON.stringify(ticket)}`);
formSubmitButton.innerText = "LOGIN";
console.error(ticket.error);
}
});
}
async function requestTicket (username, password, realm) {
const response = await requestAPI("/access/ticket", "POST", { username: `${username}@${realm}`, password }, false);
return response;
}
async function deleteAllCookies () {
await requestAPI("/access/ticket", "DELETE");
}
+62
View File
@@ -0,0 +1,62 @@
import { setAppearance, getSetting, setSetting } from "./utils.js";
window.addEventListener("DOMContentLoaded", init);
function init () {
setAppearance();
document.querySelectorAll("[id^=sync-]").forEach((v) => {
v.addEventListener("change", handleSettingsChange);
});
document.querySelector(`#sync-${getSetting("sync-scheme")}`).checked = true;
document.querySelector("#sync-rate").value = getSetting("sync-rate");
document.querySelectorAll("[id^=search-]").forEach((v) => {
v.addEventListener("change", handleSettingsChange);
});
document.querySelector(`#search-${getSetting("search-criteria")}`).checked = true;
document.querySelector("#appearance-theme").addEventListener("change", handleSettingsChange);
document.querySelector("#appearance-theme").value = getSetting("appearance-theme");
document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false);
}
function handleSettingsChange (event) {
event.preventDefault();
const form = new FormData(document.querySelector("#settings"));
const saveBtn = document.querySelector("#save");
if (getSetting("sync-scheme") !== form.get("sync-scheme")) {
saveBtn.classList.remove("disabled");
saveBtn.classList.add("enabled");
}
else if (getSetting("sync-rate") !== Number(form.get("sync-rate"))) {
saveBtn.classList.remove("disabled");
saveBtn.classList.add("enabled");
}
else if (getSetting("search-criteria") !== form.get("search-criteria")) {
saveBtn.classList.remove("disabled");
saveBtn.classList.add("enabled");
}
else if (getSetting("appearance-theme") !== form.get("appearance-theme")) {
saveBtn.classList.remove("disabled");
saveBtn.classList.add("enabled");
}
else {
saveBtn.classList.remove("enabled");
saveBtn.classList.add("disabled");
}
}
function handleSaveSettings (event) {
event.preventDefault();
const form = new FormData(document.querySelector("#settings"));
const saveBtn = document.querySelector("#save");
setSetting("sync-scheme", form.get("sync-scheme"));
setSetting("sync-rate", Number(form.get("sync-rate")));
setSetting("search-criteria", form.get("search-criteria"));
setSetting("appearance-theme", form.get("appearance-theme"));
saveBtn.classList.remove("enabled");
saveBtn.classList.add("disabled");
init();
}
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more