Compare commits

...

168 Commits

Author SHA1 Message Date
Johannes Kirschbauer
8584f3247a machine/install: fix type issues 2025-08-29 11:03:24 +02:00
Sacha Korban
d3534a2b72 fix: check if phases are non-default when running 2025-08-29 18:27:03 +10:00
clan-bot
83e51db2e7 Merge pull request 'Update nixpkgs-dev in devFlake' (#5022) from update-devFlake-nixpkgs-dev into main 2025-08-29 00:11:06 +00:00
clan-bot
4e4af8a52f Update nixpkgs-dev in devFlake 2025-08-29 00:01:29 +00:00
hsjobeki
d3e5e6edf1 Merge pull request 'ui/service: rewire to allow external selection' (#5020) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5020
2025-08-28 20:43:07 +00:00
Johannes Kirschbauer
a4277ad312 ui/service: rewire to allow external selection 2025-08-28 22:39:49 +02:00
Johannes Kirschbauer
8877f2d451 ui/scene: lift state signals to allow external access 2025-08-28 22:39:23 +02:00
Johannes Kirschbauer
9275b66bd9 ui/machine: remove unsued imports 2025-08-28 22:38:19 +02:00
Johannes Kirschbauer
6a964f37d5 ui/machineRepr: listen to highlight state 2025-08-28 22:38:19 +02:00
Johannes Kirschbauer
73f2a4f56f ui/hooks: add clickOutside hook 2025-08-28 22:37:34 +02:00
Johannes Kirschbauer
85fb0187ee ui/typography: add missing label xxs 2025-08-28 22:37:15 +02:00
Johannes Kirschbauer
db9812a08b ui/sidebar: remove unused imports 2025-08-28 22:37:05 +02:00
Johannes Kirschbauer
ca69530591 ui/search: fix divider and text styles 2025-08-28 22:36:50 +02:00
Johannes Kirschbauer
fc5b0e4113 ui/multisearch: make controlled for now 2025-08-28 22:36:21 +02:00
Johannes Kirschbauer
278af5f0f4 ui/queries: add instances query 2025-08-28 22:35:58 +02:00
Johannes Kirschbauer
e7baf25ff7 ui/toast: add toast temporarily 2025-08-28 22:35:33 +02:00
Johannes Kirschbauer
fada75144c ui/highlight: add global highlighter store 2025-08-28 22:35:15 +02:00
brianmcgee
803ef5476f Merge pull request 'feat(ui): disable button when loading state is active' (#5018) from ui/disable-button-when-loading into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5018
2025-08-28 16:00:57 +00:00
brianmcgee
016bd263d0 Merge pull request 'ui/refine-sidebar-sidepane' (#5017) from ui/refine-sidebar-sidepane into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5017
2025-08-28 15:44:27 +00:00
Brian McGee
f9143f8a5d feat(ui): disable button when loading state is active 2025-08-28 16:43:23 +01:00
Brian McGee
92eb27fcb1 feat(ui): reduce size of sidebar when selecting a machine 2025-08-28 16:40:47 +01:00
Brian McGee
0cc9b91ae8 fix(ui): quirks with sidebar sizing 2025-08-28 15:56:37 +01:00
hsjobeki
2ed3608e34 Merge pull request 'ui/clan: wire up service create' (#5016) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5016
2025-08-28 12:17:03 +00:00
Johannes Kirschbauer
a92a1a7dd1 ui/clan: wire up service create 2025-08-28 14:13:39 +02:00
Johannes Kirschbauer
9a903be6d4 ui/services: add submit handler to create the instance 2025-08-28 14:13:26 +02:00
Johannes Kirschbauer
adea270b27 ui/tagSelect: remove left over console.log 2025-08-28 14:13:05 +02:00
clan-bot
765eb142a5 Merge pull request 'Update nixpkgs-dev in devFlake' (#5014) from update-devFlake-nixpkgs-dev into main 2025-08-28 10:08:09 +00:00
clan-bot
faa1405d6b Update nixpkgs-dev in devFlake 2025-08-28 10:01:48 +00:00
hsjobeki
0c93aab818 Merge pull request 'ui/services: workflow init' (#5013) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5013
2025-08-28 08:19:01 +00:00
Johannes Kirschbauer
56923ae2c3 ui/services: workflow init 2025-08-28 10:11:15 +02:00
Johannes Kirschbauer
e2f64e1d40 ui/stepper: forward props in backButton 2025-08-28 10:10:52 +02:00
Johannes Kirschbauer
c574b84278 ui/tagSelect: simplify by requiring objects with value key 2025-08-28 10:10:25 +02:00
Johannes Kirschbauer
640f15d55e ui/search: remove portal, fix styling 2025-08-28 10:09:41 +02:00
Johannes Kirschbauer
789d326273 ui/queries: add list tags query 2025-08-28 10:09:03 +02:00
clan-bot
1763d85d91 Merge pull request 'Update nixpkgs-dev in devFlake' (#5011) from update-devFlake-nixpkgs-dev into main 2025-08-27 20:10:01 +00:00
clan-bot
082fa05083 Update nixpkgs-dev in devFlake 2025-08-27 20:01:45 +00:00
brianmcgee
9ed7190606 Merge pull request 'fix(ui): icon alignment in alerts' (#5008) from ui/fix-icon-misalignment into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5008
2025-08-27 16:33:29 +00:00
Brian McGee
6c22539dd4 fix(ui): icon alignment in alerts
Closes #5004
2025-08-27 17:30:08 +01:00
Luis Hebendanz
e6819ede61 Merge pull request 'docs/update: refactor machine update guide' (#4997) from docs-10 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4997
2025-08-27 15:40:34 +00:00
Qubasa
186a760529 docs: fixup links to networking guide, improve introduction. 2025-08-27 17:37:15 +02:00
clan-bot
a84aee7b0c Merge pull request 'Update nixos-facter-modules' (#5007) from update-nixos-facter-modules into main 2025-08-27 15:10:12 +00:00
clan-bot
cab2fa44ba Update nixos-facter-modules 2025-08-27 15:00:55 +00:00
Mic92
5962149e55 Merge pull request 'remove diskId from existing templates' (#5006) from drop-diskid into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5006
2025-08-27 13:31:23 +00:00
Jörg Thalheim
00f9d08a4b remove diskId from existing templates
we don't have a replacement yet, but at least this will work.
2025-08-27 15:24:59 +02:00
clan-bot
3d0c843308 Merge pull request 'Update nixpkgs-dev in devFlake' (#5003) from update-devFlake-nixpkgs-dev into main 2025-08-27 10:08:09 +00:00
clan-bot
847138472b Update nixpkgs-dev in devFlake 2025-08-27 10:01:50 +00:00
Johannes Kirschbauer
c7786a59fd docs/update: refactor machine update guide
Restructured page: core workflow first, advanced usage after.

Improved grammar, phrasing, and capitalization (Clan CLI, apostrophes).

Added warnings/notes for buildHost and CPU architecture.

Polished code snippets and CLI examples for clarity.
2025-08-27 10:26:53 +02:00
hsjobeki
3b2d357f10 Merge pull request 'api/modules: unify duplicate endpoints for {modules, instances}' (#4994) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4994
2025-08-27 07:13:46 +00:00
DavHau
a83dbf604c Merge pull request 'vars: always generate dependents' (#4996) from vars into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4996
2025-08-27 05:59:13 +00:00
DavHau
f77456a123 vars: simplify graph implementation, remove obsolete closure functions
- full_closure is obsolete since it is the same as calling requested_closure with the full list of generators.
- minimal_closure is obsolete as well. Since the recent addition of dependents to the closure via 3d2127ce1e it is essentially the same as the all_missing_closure
2025-08-27 12:50:59 +07:00
DavHau
6e4c3a638d vars: move graph tests to separate file 2025-08-27 11:47:46 +07:00
DavHau
3d2127ce1e vars: always generate dependents
Even for the minimal closure case (when a specific generator was picked), we should still force regeneration of all dependents, as otherwise we risk keeping outdated dependents from previous generations
2025-08-27 11:47:46 +07:00
DavHau
a4a5916fa2 vars: generate over multiple machines at once 2025-08-27 11:45:45 +07:00
Johannes Kirschbauer
f6727055cd api/modules: unify duplicate endpoints for {modules, instances} 2025-08-26 21:44:58 +02:00
hsjobeki
0517d87caa Merge pull request 'api/instances: add list service instances' (#4993) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4993
2025-08-26 16:52:53 +00:00
Johannes Kirschbauer
89e587592c api/instances: add list service instances 2025-08-26 18:47:08 +02:00
hsjobeki
439495d738 Merge pull request 'ui/search: fix height of overflow' (#4992) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4992
2025-08-26 16:46:29 +00:00
Johannes Kirschbauer
0b2fd681be ui/search: fix height of overflow 2025-08-26 18:43:09 +02:00
hsjobeki
41de615331 Merge pull request 'ui/services: add more features to components' (#4988) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4988
2025-08-26 16:40:51 +00:00
Johannes Kirschbauer
b7639b1d81 ui/services: fix some background colors 2025-08-26 18:35:43 +02:00
Johannes Kirschbauer
602879c9e4 ui/services: workflow select service 2025-08-26 18:35:43 +02:00
Johannes Kirschbauer
53e16242b9 ui/search: add loading state 2025-08-26 18:35:43 +02:00
Johannes Kirschbauer
24c5146763 ui/search: fix height calculate to avoid overlaying components 2025-08-26 18:35:43 +02:00
Johannes Kirschbauer
dca7aa0487 ui/modules: hook up list modules query 2025-08-26 18:35:43 +02:00
Johannes Kirschbauer
647bc4e4df api/list_modules: return a simpler list of modules 2025-08-26 18:35:43 +02:00
brianmcgee
1c80223fe3 Merge pull request 'feat(ui): remove light typography weight' (#4991) from misc/fixes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4991
2025-08-26 16:18:21 +00:00
Brian McGee
7ac9b00398 feat(ui): remove light typography weight 2025-08-26 16:13:53 +01:00
brianmcgee
d37c9e3b04 Merge pull request 'feat(ui): refine remove clan button copy' (#4986) from ui/refine-remove-clan into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4986
2025-08-26 14:44:12 +00:00
Brian McGee
0fe9d0e157 feat(ui): refine remove clan button copy 2025-08-26 15:40:24 +01:00
Mic92
5479c767c1 Merge pull request 'try{300,301,400}: fix' (#4984) from checkout-update into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4984
2025-08-26 14:31:57 +00:00
brianmcgee
edc389ba4b Merge pull request 'feat(ui): change button font to normal instead of monospace' (#4985) from ui/change-button-font into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4985
2025-08-26 14:23:10 +00:00
Jörg Thalheim
4cb17d42e1 PLR2004: fix 2025-08-26 16:21:15 +02:00
Jörg Thalheim
f26499edb8 pyproject.toml: add descriptions to each rule 2025-08-26 16:21:15 +02:00
Jörg Thalheim
2857cb7ed8 remove various ignores that had no actual issue 2025-08-26 16:21:15 +02:00
Jörg Thalheim
3168fecd52 PT100: fix 2025-08-26 16:21:15 +02:00
Jörg Thalheim
24c20ff243 TRY400: fix 2025-08-26 16:21:15 +02:00
Jörg Thalheim
8ba8fda54b RUF100: fix 2025-08-26 16:21:15 +02:00
Brian McGee
0992a47b00 feat(ui): change button font to normal instead of monospace 2025-08-26 15:13:30 +01:00
Jörg Thalheim
d5b09f18ed RET504: fix 2025-08-26 15:55:23 +02:00
Jörg Thalheim
fb2fe36c87 SIM112: fix 2025-08-26 15:55:23 +02:00
hsjobeki
3db51887b1 Merge pull request 'ui/select machines/tags: add custom combobox' (#4983) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4983
2025-08-26 13:51:50 +00:00
Johannes Kirschbauer
24f3bcca57 ui/select: rename to tagSelect 2025-08-26 15:48:28 +02:00
Johannes Kirschbauer
85006c8103 ui/select machines/tags: add custom combobox
This just renders machines and tags as chips
onclick will open another combobox
2025-08-26 15:47:22 +02:00
Jörg Thalheim
db5571d623 SIM108: fix 2025-08-26 15:23:36 +02:00
Jörg Thalheim
d4bdaec586 SIM102: fix 2025-08-26 15:22:25 +02:00
Jörg Thalheim
cb9c8e5b5a try{300,301,400}: fix 2025-08-26 15:17:16 +02:00
Mic92
0a1802c341 Merge pull request 'github/repo-sync: v4 -> v5' (#4982) from checkout-update into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4982
2025-08-26 12:59:10 +00:00
Mic92
dfae1a4429 Merge pull request 'PLC0415: fix' (#4981) from ruff into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4981
2025-08-26 12:58:37 +00:00
Jörg Thalheim
c1dc73a21b github/repo-sync: v4 -> v5 2025-08-26 14:54:41 +02:00
Jörg Thalheim
8145740cc1 api: lazly load Api options 2025-08-26 14:48:20 +02:00
Jörg Thalheim
b2a54f5b0d PLC0415: fix 2025-08-26 14:46:42 +02:00
hsjobeki
9c9adc6e16 Merge pull request 'ui/tags: refactor generic children and icon' (#4960) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4960
2025-08-26 12:14:41 +00:00
Johannes Kirschbauer
f7cde8eb0f ui/tags: refactor generic children and icon 2025-08-26 14:11:14 +02:00
DavHau
501d020562 vars: retrieve generators for multiple machines
This is necessary ground work for fixing regeneration behavior spanning over multiple machines
2025-08-26 18:55:54 +07:00
Mic92
a9bafd71e1 Merge pull request 'templates/list: we can compute the lenght of an dictionary directly' (#4980) from ruff into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4980
2025-08-26 11:45:44 +00:00
Mic92
166e4b8081 Merge pull request 'add feature: ask for vars input confirmation, and fail after 3 attempts. fixes accidental misinputs when typing passwords!' (#4920) from adeci-2xconfirm into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4920
2025-08-26 11:41:16 +00:00
Jörg Thalheim
c3eb40f17a templates/list: we can compute the lenght of an dictionary directly 2025-08-26 13:39:49 +02:00
Jörg Thalheim
7330285150 prompt/multiline: strip final newline just like hidden prompt 2025-08-26 13:35:12 +02:00
Luis Hebendanz
8cf8573c61 Merge pull request 'clan-app: Maybe fix the logging errror ValueError: I/O operation on closed file.' (#4974) from Qubasa/clan-core:fix_logging into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4974
2025-08-26 11:32:48 +00:00
Jörg Thalheim
5bfa0d7a9d prompt: catch EOF errors 2025-08-26 13:26:49 +02:00
adeci
8ea2dd9b72 add feature: ask for vars input confirmation, and fail after 3 attempts. fixes accidental misinputs when typing passwords! 2025-08-26 13:26:49 +02:00
Mic92
6efcade56a Merge pull request 'Enable "all" ruff lint fixes' (#4978) from ruff into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4978
2025-08-26 11:26:47 +00:00
Jörg Thalheim
6d2372be56 machines/update: fix incorrecct nixos-rebuild command 2025-08-26 13:11:43 +02:00
brianmcgee
626af4691b Merge pull request 'feat(ui): pin stepper buttons to the bottom' (#4979) from ui/pinned-stepper-buttons into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4979
2025-08-26 11:07:30 +00:00
Jörg Thalheim
63697ac4b1 various fixes 2025-08-26 13:07:22 +02:00
Brian McGee
0ebb1f0c66 feat(ui): pin stepper buttons to the bottom
Closes #4968
2025-08-26 12:02:28 +01:00
Jörg Thalheim
1dda60847e PLW0602: fix 2025-08-26 12:57:31 +02:00
Jörg Thalheim
a7bce4cb19 pyproject: enable all lints 2025-08-26 12:57:31 +02:00
Mic92
a5474bc25f Merge pull request 'ruff-7-misc' (#4939) from ruff-7-misc into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4939
2025-08-26 10:43:12 +00:00
Jörg Thalheim
f634b8f1fb merge-after-ci: move away from writePython3Bin
this is one is doing checks we don't want because we already have ruff.
2025-08-26 12:39:50 +02:00
brianmcgee
0ad40a0233 Merge pull request 'ui/refine-select-folder-onboarding' (#4977) from ui/refine-select-folder-onboarding into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4977
2025-08-26 10:30:23 +00:00
Luis Hebendanz
78abc36cd3 Merge pull request 'clan-cli: clan machines update-hardware-config now uses kexec, and supports non NixOS targets' (#4948) from Qubasa/clan-core:fix_update_hardware_config into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4948
2025-08-26 10:16:59 +00:00
Brian McGee
f5158b068f feat(ui): reduce size of sidebar pane
Make it clearer the distinction between parent and child panes.
2025-08-26 11:16:03 +01:00
Jörg Thalheim
e6066a6cb1 spawn_tor: catch OSError and wrap as ClanError 2025-08-26 12:12:29 +02:00
clan-bot
fc8b66effa Merge pull request 'Update nixpkgs-dev in devFlake' (#4972) from update-devFlake-nixpkgs-dev into main 2025-08-26 10:09:59 +00:00
Qubasa
16b92963fd clan-app: Maybe fix the logging errror ValueError: I/O operation on closed file. 2025-08-26 12:08:45 +02:00
Brian McGee
2ff3d871ac feat(ui): allow placing machines directly next to each other 2025-08-26 11:02:58 +01:00
clan-bot
108936ef07 Update nixpkgs-dev in devFlake 2025-08-26 10:01:48 +00:00
Jörg Thalheim
c45d4cfec9 D413/D212: fix 2025-08-26 12:01:47 +02:00
Jörg Thalheim
64217e1281 G001: fix 2025-08-26 12:01:47 +02:00
Jörg Thalheim
d1421bb534 EXE002: fix 2025-08-26 12:01:47 +02:00
Jörg Thalheim
ac20514a8e EXE001: fix 2025-08-26 12:01:47 +02:00
Jörg Thalheim
79c4e73a15 test_http_api: remove unused logging middleware 2025-08-26 12:01:47 +02:00
Jörg Thalheim
61a647b436 PLR1704: fix 2025-08-26 12:01:47 +02:00
Jörg Thalheim
c9a709783a BLE001: fix 2025-08-26 12:01:47 +02:00
Kenji Berthold
c55b369899 Merge pull request 'docs: Add edit button to documentation pages' (#4969) from kenji/ke-add-repo-url into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4969
2025-08-26 09:59:14 +00:00
Brian McGee
084b8bacd3 fix(ui): typos in install machine workflow 2025-08-26 10:52:52 +01:00
a-kenji
47ad7d8a95 docs: Add edit button to documentation pages
Closes: #4966
2025-08-26 11:52:08 +02:00
a-kenji
3798808013 docs: Fix edit uri 2025-08-26 11:51:53 +02:00
Brian McGee
43a39267f3 feat(ui): make the intention of the select folder button clearer in Onboarding 2025-08-26 10:44:42 +01:00
Mic92
db94ea2d2e Merge pull request 'Misc ruff fixes' (#4965) from ruff-foo into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4965
2025-08-26 09:44:06 +00:00
hsjobeki
f0533f9bba Merge pull request 'ui/scene: dont snap to occupied positions' (#4967) from fixes-ui into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4967
2025-08-26 09:43:59 +00:00
Johannes Kirschbauer
360048fd04 ui/scene: dont snap to occupied positions 2025-08-26 11:40:38 +02:00
Jörg Thalheim
8f8426de52 PGH003: fix 2025-08-26 11:36:38 +02:00
Qubasa
4bce390e64 clan-cli: clan machiens update-hardware-config now uses kexec, and supports non NixOS targets 2025-08-26 11:35:44 +02:00
DavHau
2b7837e2b6 Merge pull request 'GUI: add port option for ssh remote' (#4961) from dave into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4961
2025-08-26 09:33:26 +00:00
Jörg Thalheim
cbf9678534 flake/prefetch: Fix unconditional truthy string causes always-True 2025-08-26 11:07:57 +02:00
Jörg Thalheim
b38b10c9a6 automatic ruff fixes 2025-08-26 11:07:57 +02:00
Jörg Thalheim
31cbb7dc00 PLC0415: fix 2025-08-26 11:07:57 +02:00
hsjobeki
0fa4377793 Merge pull request 'ui/scene: add reload button' (#4962) from fixes-ui into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4962
2025-08-26 09:01:45 +00:00
Johannes Kirschbauer
7b0d10e8c2 ui/queries: remove annoying refetch interval, invalidate on change instead 2025-08-26 10:58:39 +02:00
Johannes Kirschbauer
bb41adab4b ui/scene: fix syncing remote and local state 2025-08-26 10:40:09 +02:00
DavHau
648aa7dc59 Merge pull request 'API: fix serialization of union types' (#4963) from serde into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4963
2025-08-26 08:26:13 +00:00
DavHau
3073969c92 vars/tests: add comments 2025-08-26 15:17:41 +07:00
DavHau
2f1dc3a33d API: fix serialization of union types
Due to this bug in serde.py, the run_generators API id not work for the frontend
2025-08-26 15:16:55 +07:00
Johannes Kirschbauer
b707dcea2d ui/scene: add reload button 2025-08-26 10:08:05 +02:00
Johannes Kirschbauer
4f0c8025b2 ui/queries: remove annoying refetch interval, invalidate on change instead 2025-08-26 10:07:41 +02:00
pinpox
b91bee537a Merge pull request 'Enable state-version in defaults' (#4711) from default-state-version into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4711
2025-08-26 07:49:46 +00:00
pinpox
7207a3e8cd Cleanup state-version test 2025-08-26 09:44:01 +02:00
pinpox
ac675a5af0 Merge pull request 'Add coredns module' (#4837) from coredns into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4837
2025-08-26 07:39:57 +00:00
pinpox
64caebde62 service/state-version: drop 2025-08-26 09:32:36 +02:00
pinpox
4934884e0c Enable state-version in defaults 2025-08-26 09:32:36 +02:00
pinpox
22cd9baee2 Merge pull request 'Improve inventory docs' (#4933) from inventory-docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4933
2025-08-26 07:32:23 +00:00
pinpox
84232b5355 Improve inventory docs 2025-08-26 09:29:25 +02:00
DavHau
5bc7c255c1 GUI: add port option for ssh remote
I need this for testing with a local VM, which ahs a different port than 22.

This also disables host key checking, as there is currently no workflow int he GUI which can handle a mismatch, which mismatches are common.
2025-08-26 13:28:27 +07:00
clan-bot
d11d83f699 Merge pull request 'Update clan-core-for-checks in devFlake' (#4959) from update-devFlake-clan-core-for-checks into main 2025-08-26 05:08:08 +00:00
clan-bot
2ef1b2a8fa Update clan-core-for-checks in devFlake 2025-08-26 05:01:46 +00:00
clan-bot
f7414d7e6e Merge pull request 'Update clan-core-for-checks in devFlake' (#4957) from update-devFlake-clan-core-for-checks into main 2025-08-26 00:08:04 +00:00
clan-bot
ab384150b2 Merge pull request 'Update nixpkgs-dev in devFlake' (#4958) from update-devFlake-nixpkgs-dev into main 2025-08-26 00:07:37 +00:00
clan-bot
0b6939ffee Update nixpkgs-dev in devFlake 2025-08-26 00:01:48 +00:00
clan-bot
bc6a1a9d17 Update clan-core-for-checks in devFlake 2025-08-26 00:01:28 +00:00
clan-bot
7055461cf0 Merge pull request 'Update clan-core-for-checks in devFlake' (#4956) from update-devFlake-clan-core-for-checks into main 2025-08-25 20:10:56 +00:00
clan-bot
a9564df6a9 Update clan-core-for-checks in devFlake 2025-08-25 20:01:26 +00:00
brianmcgee
e2dfc74d02 Merge pull request 'feat(ui): fix layout and size of install progress and done screens' (#4954) from ui/fix-install-modal-sizes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4954
2025-08-25 16:55:48 +00:00
Brian McGee
326cb60aea feat(ui): fix layout and size of install progress and done screens 2025-08-25 17:51:20 +01:00
brianmcgee
68b264970a Merge pull request 'feat(ui): set loading status on update hardware report button in install workflow' (#4951) from ui/update-hardware-report-loading-state into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4951
2025-08-25 16:46:16 +00:00
Brian McGee
1fa4ef82e9 feat(ui): set loading status on update hardware report button in install workflow 2025-08-25 17:32:15 +01:00
pinpox
ec70de406b Add coredns module 2025-08-21 10:29:54 +02:00
203 changed files with 3900 additions and 1769 deletions

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'clan-lol'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/create-github-app-token@v2

View File

@@ -302,7 +302,8 @@
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
f"nonrootuser@localhost:{ssh_conn.host_port}"
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
"--yes"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
@@ -326,7 +327,9 @@
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
f"nonrootuser@localhost:{ssh_conn.host_port}"
"--target-host",
f"nonrootuser@localhost:{ssh_conn.host_port}",
"--yes"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)

View File

@@ -0,0 +1,68 @@
This module enables hosting clan-internal services easily, which can be resolved
inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`)
and exposing endpoints from a machine to others, which will be
accessible under `http://<service>.clan` in your browser.
The service consists of two roles:
- A `server` role: This is the DNS-server that will be queried when trying to
resolve clan-internal services. It defines the top-level domain.
- A `default` role: This does two things. First, it sets up the nameservers so
thatclan-internal queries are resolved via the `server` machine, while
external queries are resolved as normal via DHCP. Second, it allows exposing
services (see example below).
## Example Usage
Here the machine `dnsserver` is designated as internal DNS-server for the TLD
`.foo`. `server01` will host an application that shall be reachable at
`http://one.foo` and `server02` is going to be reachable at `http://two.foo`.
`client` is any other machine that is part of the clan but does not host any
services.
When `client` tries to resolve `http://one.foo`, the DNS query will be
routed to `dnsserver`, which will answer with `192.168.1.3`. If it tries to
resolve some external domain (e.g. `https://clan.lol`), the query will not be
routed to `dnsserver` but resolved as before, via the nameservers advertised by
DHCP.
```nix
inventory = {
machines = {
dnsserver = { }; # 192.168.1.2
server01 = { }; # 192.168.1.3
server02 = { }; # 192.168.1.4
client = { }; # 192.168.1.5
};
instances = {
coredns = {
module.name = "@clan/coredns";
module.input = "self";
# Add the default role to all machines, including `client`
roles.default.tags.all = { };
# DNS server
roles.server.machines."dnsserver".settings = {
ip = "192.168.1.2";
tld = "foo";
};
# First service
roles.default.machines."server01".settings = {
ip = "192.168.1.3";
services = [ "one" ];
};
# Second service
roles.default.machines."server02".settings = {
ip = "192.168.1.4";
services = [ "two" ];
};
};
};
};
```

View File

@@ -0,0 +1,157 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "coredns";
manifest.description = "Clan-internal DNS and service exposure";
manifest.categories = [ "Network" ];
manifest.readme = builtins.readFile ./README.md;
roles.server = {
interface =
{ lib, ... }:
{
options.tld = lib.mkOption {
type = lib.types.str;
default = "clan";
description = ''
Top-level domain for this instance. All services below this will be
resolved internally.
'';
};
options.ip = lib.mkOption {
type = lib.types.str;
# TODO: Set a default
description = "IP for the DNS to listen on";
};
};
perInstance =
{
roles,
settings,
...
}:
{
nixosModule =
{
lib,
pkgs,
...
}:
{
networking.firewall.allowedTCPPorts = [ 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
services.coredns =
let
# Get all service entries for one host
hostServiceEntries =
host:
lib.strings.concatStringsSep "\n" (
map (
service: "${service} IN A ${roles.default.machines.${host}.settings.ip} ; ${host}"
) roles.default.machines.${host}.settings.services
);
zonefile = pkgs.writeTextFile {
name = "db.${settings.tld}";
text = ''
$TTL 3600
@ IN SOA ns.${settings.tld}. admin.${settings.tld}. 1 7200 3600 1209600 3600
IN NS ns.${settings.tld}.
ns IN A ${settings.ip} ; DNS server
''
+ (lib.strings.concatStringsSep "\n" (
map (host: hostServiceEntries host) (lib.attrNames roles.default.machines)
));
};
in
{
enable = true;
config = ''
. {
forward . 1.1.1.1
cache 30
}
${settings.tld} {
file ${zonefile}
}
'';
};
};
};
};
roles.default = {
interface =
{ lib, ... }:
{
options.services = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Service endpoints this host exposes (without TLD). Each entry will
be resolved to <entry>.<tld> using the configured top-level domain.
'';
};
options.ip = lib.mkOption {
type = lib.types.str;
# TODO: Set a default
description = "IP on which the services will listen";
};
};
perInstance =
{ roles, ... }:
{
nixosModule =
{ lib, ... }:
{
networking.nameservers = map (m: "127.0.0.1:5353#${roles.server.machines.${m}.settings.tld}") (
lib.attrNames roles.server.machines
);
services.resolved.domains = map (m: "~${roles.server.machines.${m}.settings.tld}") (
lib.attrNames roles.server.machines
);
services.unbound = {
enable = true;
settings = {
server = {
port = 5353;
verbosity = 2;
interface = [ "127.0.0.1" ];
access-control = [ "127.0.0.0/8 allow" ];
do-not-query-localhost = "no";
domain-insecure = map (m: "${roles.server.machines.${m}.settings.tld}.") (
lib.attrNames roles.server.machines
);
};
# Default: forward everything else to DHCP-provided resolvers
forward-zone = [
{
name = ".";
forward-addr = "127.0.0.53@53"; # Forward to systemd-resolved
}
];
stub-zone = map (m: {
name = "${roles.server.machines.${m}.settings.tld}.";
stub-addr = "${roles.server.machines.${m}.settings.ip}";
}) (lib.attrNames roles.server.machines);
};
};
};
};
};
}

View File

@@ -3,14 +3,16 @@ let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules.state-version = module;
clan.modules = {
coredns = module;
};
perSystem =
{ ... }:
{
clan.nixosTests.state-version = {
clan.nixosTests.coredns = {
imports = [ ./tests/vm/default.nix ];
clan.modules."@clan/state-version" = module;
clan.modules."@clan/coredns" = module;
};
};
}

View File

@@ -0,0 +1,113 @@
{
...
}:
{
name = "coredns";
clan = {
directory = ./.;
test.useContainers = true;
inventory = {
machines = {
dns = { }; # 192.168.1.2
server01 = { }; # 192.168.1.3
server02 = { }; # 192.168.1.4
client = { }; # 192.168.1.1
};
instances = {
coredns = {
module.name = "@clan/coredns";
module.input = "self";
roles.default.tags.all = { };
# First service
roles.default.machines."server01".settings = {
ip = "192.168.1.3";
services = [ "one" ];
};
# Second service
roles.default.machines."server02".settings = {
ip = "192.168.1.4";
services = [ "two" ];
};
# DNS server
roles.server.machines."dns".settings = {
ip = "192.168.1.2";
tld = "foo";
};
};
};
};
};
nodes = {
dns =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.net-tools ];
};
client =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.net-tools ];
};
server01 = {
services.nginx = {
enable = true;
virtualHosts."one.foo" = {
locations."/" = {
return = "200 'test server response one'";
extraConfig = "add_header Content-Type text/plain;";
};
};
};
};
server02 = {
services.nginx = {
enable = true;
virtualHosts."two.foo" = {
locations."/" = {
return = "200 'test server response two'";
extraConfig = "add_header Content-Type text/plain;";
};
};
};
};
};
testScript = ''
import json
start_all()
machines = [server01, server02, dns, client]
for m in machines:
m.systemctl("start network-online.target")
for m in machines:
m.wait_for_unit("network-online.target")
# import time
# time.sleep(2333333)
# This should work, but is borken in tests i think? Instead we dig directly
# client.succeed("curl -k -v http://one.foo")
# client.succeed("curl -k -v http://two.foo")
answer = client.succeed("dig @192.168.1.2 one.foo")
assert "192.168.1.3" in answer, "IP not found"
answer = client.succeed("dig @192.168.1.2 two.foo")
assert "192.168.1.4" in answer, "IP not found"
'';
}

View File

@@ -1,37 +0,0 @@
This service generates the `system.stateVersion` of the nixos installation
automatically.
Possible values:
[system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
## Usage
The following configuration will set `stateVersion` for all machines:
```
inventory.instances = {
state-version = {
module = {
name = "state-version";
input = "clan";
};
roles.default.tags.all = { };
};
```
## Migration
If you are already setting `system.stateVersion`, either let the automatic
generation happen, or trigger the generation manually for the machine. The
service will take the specified version, if one is already supplied through the
config.
To manually generate the version for a specified machine run:
```
clan vars generate [MACHINE]
```
If the setting was already set, you can then remove `system.stateVersion` from
your machine configuration. For new machines, just import the service as shown
above.

View File

@@ -1,50 +0,0 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/state-version";
manifest.description = "Automatically generate the state version of the nixos installation.";
manifest.categories = [ "System" ];
manifest.readme = builtins.readFile ./README.md;
roles.default = {
perInstance =
{ ... }:
{
nixosModule =
{
config,
lib,
...
}:
let
var = config.clan.core.vars.generators.state-version.files.version or { };
in
{
warnings = [
''
The clan.state-version service is deprecated and will be
removed on 2025-07-15 in favor of a nix option.
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
''
];
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
clan.core.vars.generators.state-version = {
files.version = {
secret = false;
value = lib.mkDefault config.system.nixos.release;
};
runtimeInputs = [ ];
script = ''
echo -n ${config.system.stateVersion} > "$out"/version
'';
};
};
};
};
}

View File

@@ -1,22 +0,0 @@
{ lib, ... }:
{
name = "service-state-version";
clan = {
directory = ./.;
inventory = {
machines.server = { };
instances.default = {
module.name = "@clan/state-version";
module.input = "self";
roles.default.machines."server" = { };
};
};
};
nodes.server = { };
testScript = lib.mkDefault ''
start_all()
'';
}

View File

@@ -12,6 +12,11 @@ import ipaddress
import sys
from pathlib import Path
# Constants for argument count validation
MIN_ARGS_BASE = 4
MIN_ARGS_CONTROLLER = 5
MIN_ARGS_PEER = 5
def hash_string(s: str) -> str:
"""Generate SHA256 hash of string."""
@@ -39,8 +44,7 @@ def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
prefix = f"fd{prefix_bits:08x}"
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
network = ipaddress.IPv6Network(prefix_formatted)
return network
return ipaddress.IPv6Network(prefix_formatted)
def generate_controller_subnet(
@@ -60,9 +64,7 @@ def generate_controller_subnet(
# The controller subnet is at base_prefix:controller_id::/56
base_int = int(base_network.network_address)
controller_subnet_int = base_int | (controller_id << (128 - 56))
controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
return controller_subnet
return ipaddress.IPv6Network((controller_subnet_int, 56))
def generate_peer_suffix(peer_name: str) -> str:
@@ -76,12 +78,11 @@ def generate_peer_suffix(peer_name: str) -> str:
suffix_bits = h[:16]
# Format as IPv6 suffix without leading colon
suffix = f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
return suffix
return f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
def main() -> None:
if len(sys.argv) < 4:
if len(sys.argv) < MIN_ARGS_BASE:
print(
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>",
)
@@ -95,7 +96,7 @@ def main() -> None:
base_network = generate_ula_prefix(instance_name)
if node_type == "controller":
if len(sys.argv) < 5:
if len(sys.argv) < MIN_ARGS_CONTROLLER:
print("Controller name required")
sys.exit(1)
@@ -111,7 +112,7 @@ def main() -> None:
(output_dir / "prefix").write_text(prefix_str)
elif node_type == "peer":
if len(sys.argv) < 5:
if len(sys.argv) < MIN_ARGS_PEER:
print("Peer name required")
sys.exit(1)

12
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1756133826,
"narHash": "sha256-In3u7UVSjPzX9u4Af9K/jVy4MMAZBzxByMe4GREpHBo=",
"lastModified": 1756166884,
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
"ref": "main",
"rev": "c4da43da0f583bd3cbcfd1f3acf74f9dc51b8fdd",
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -84,11 +84,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1756104823,
"narHash": "sha256-wRzHREXDOrbCjy+sqo4t3JoInbB2PuhXIUa8NWdh9tk=",
"lastModified": 1756400612,
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d7967bed5381e65208f4fb8d5502e3c36bb94759",
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
"type": "github"
},
"original": {

View File

@@ -1,13 +1,11 @@
{
lib,
config,
...
}:
let
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
mirrorBoot = idx: {
# suffix is to prevent disk name collisions
name = idx + suffix;
name = idx;
type = "disk";
device = "/dev/disk/by-id/${idx}";
content = {

View File

@@ -1,13 +1,11 @@
{
lib,
config,
...
}:
let
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
mirrorBoot = idx: {
# suffix is to prevent disk name collisions
name = idx + suffix;
name = idx;
type = "disk";
device = "/dev/disk/by-id/${idx}";
content = {

View File

@@ -2,7 +2,7 @@ site_name: Clan Documentation
site_url: https://docs.clan.lol
repo_url: https://git.clan.lol/clan/clan-core/
repo_name: "_>"
edit_uri: _edit/main/docs/docs/
edit_uri: _edit/main/docs/site/
validation:
omitted_files: warn
@@ -94,6 +94,7 @@ nav:
- reference/clanServices/index.md
- reference/clanServices/admin.md
- reference/clanServices/borgbackup.md
- reference/clanServices/coredns.md
- reference/clanServices/data-mesher.md
- reference/clanServices/dyndns.md
- reference/clanServices/emergency-access.md
@@ -106,7 +107,6 @@ nav:
- reference/clanServices/monitoring.md
- reference/clanServices/packages.md
- reference/clanServices/sshd.md
- reference/clanServices/state-version.md
- reference/clanServices/syncthing.md
- reference/clanServices/trusted-nix-caches.md
- reference/clanServices/users.md
@@ -173,6 +173,7 @@ theme:
- content.code.annotate
- content.code.copy
- content.tabs.link
- content.action.edit
icon:
repo: fontawesome/brands/git
custom_dir: overrides

View File

@@ -48,7 +48,7 @@ CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
CLAN_MODULES_VIA_SERVICE = os.environ.get("CLAN_MODULES_VIA_SERVICE")
OUT = os.environ.get("out")
OUT = os.environ.get("out") # noqa: SIM112
def sanitize(text: str) -> str:
@@ -551,8 +551,7 @@ def options_docs_from_tree(
return output
md = render_tree(root)
return md
return render_tree(root)
if __name__ == "__main__":

View File

@@ -1,16 +1,22 @@
# Using `clanServices`
# Using the Inventory
Clan's `clanServices` system is a composable way to define and deploy services across machines.
Clan's inventory system is a composable way to define and deploy services across
machines.
This guide shows how to **instantiate** a `clanService`, explains how service definitions are structured in your inventory, and how to pick or create services from modules exposed by flakes.
This guide shows how to **instantiate** a `clanService`, explains how service
definitions are structured in your inventory, and how to pick or create services
from modules exposed by flakes.
The term **Multi-host-modules** was introduced previously in the [nixus repository](https://github.com/infinisil/nixus) and represents a similar concept.
The term **Multi-host-modules** was introduced previously in the [nixus
repository](https://github.com/infinisil/nixus) and represents a similar
concept.
---
______________________________________________________________________
## Overview
Services are used in `inventory.instances`, and then they attach to *roles* and *machines* — meaning you decide which machines run which part of the service.
Services are used in `inventory.instances`, and assigned to *roles* and
*machines* -- meaning you decide which machines run which part of the service.
For example:
@@ -18,116 +24,135 @@ For example:
inventory.instances = {
borgbackup = {
roles.client.machines."laptop" = {};
roles.client.machines."server1" = {};
roles.client.machines."workstation" = {};
roles.server.machines."backup-box" = {};
};
}
```
This says: Run borgbackup as a *client* on my *laptop* and *server1*, and as a *server* on *backup-box*.”
This says: "Run borgbackup as a *client* on my *laptop* and *workstation*, and
as a *server* on *backup-box*". `client` and `server` are roles defined by the
`borgbackup` service.
## Module source specification
Each instance includes a reference to a **module specification** this is how Clan knows which service module to use and where it came from.
Usually one would just use `imports` but we needd to make the `module source` configurable via Python API.
By default it is not required to specify the `module`, in which case it defaults to the preprovided services of clan-core.
Each instance includes a reference to a **module specification** -- this is how
Clan knows which service module to use and where it came from.
---
## Override Example
It is not required to specify the `module.input` parameter, in which case it
defaults to the pre-provided services of clan-core. In a similar fashion, the
`module.name` parameter can also be omitted, it will default to the name of the
instance.
Example of instantiating a `borgbackup` service using `clan-core`:
```nix
inventory.instances = {
# Instance Name: Different name for this 'borgbackup' instance
borgbackup = {
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
module = {
name = "borgbackup"; # <-- Name of the module (optional)
input = "clan-core"; # <-- The flake input where the service is defined (optional)
};
borgbackup = { # <- Instance name
# This can be partially/fully specified,
# - If the instance name is not the name of the module
# - If the input is not clan-core
# module = {
# name = "borgbackup"; # Name of the module (optional)
# input = "clan-core"; # The flake input where the service is defined (optional)
# };
# Participation of the machines is defined via roles
# Right side needs to be an attribute set. Its purpose will become clear later
roles.client.machines."machine-a" = {};
roles.server.machines."backup-host" = {};
};
}
```
If you used `clan-core` as an input attribute for your flake:
## Module Settings
Each role might expose configurable options. See clan's [clanServices
reference](../reference/clanServices/index.md) for all available options.
Settings can be set in per-machine or per-role. The latter is applied to all
machines that are assigned to that role.
```nix
# ↓ module.input = "clan-core"
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
```
## Simplified Example
If only one instance is needed for a service and the service is a clan core service, the `module` definition can be omitted.
```nix
# Simplified way of specifying a single instance
inventory.instances = {
# instance name is `borgbackup` -> clan core module `borgbackup` will be loaded.
borgbackup = {
# Participation of the machines is defined via roles
# Right side needs to be an attribute set. Its purpose will become clear later
roles.client.machines."machine-a" = {};
roles.server.machines."backup-host" = {};
};
}
```
## Configuration Example
Each role might expose configurable options
See clan's [clanServices reference](../reference/clanServices/index.md) for available options
```nix
inventory.instances = {
borgbackup-example = {
module = {
name = "borgbackup";
input = "clan-core";
};
# Settings for 'machine-a'
roles.client.machines."machine-a" = {
# 'client' -Settings of 'machine-a'
settings = {
backupFolders = [
/home
/var
];
};
# ---------------------------
};
roles.server.machines."backup-host" = {};
# Settings for all machines of the role "server"
roles.server.settings = {
directory = "/var/lib/borgbackup";
};
};
}
```
## Tags
Multiple members can be defined using tags as follows
Tags can be used to assign multiple machines to a role at once. It can be thought of as a grouping mechanism.
For example using the `all` tag for services that you want to be configured on all
your machines is a common pattern.
The following example could be used to backup all your machines to a common
backup server
```nix
inventory.instances = {
borgbackup-example = {
module = {
name = "borgbackup";
input = "clan-core";
};
#
# The 'all' -tag targets all machines
roles.client.tags."all" = {};
# ---------------------------
borgbackup = {
# "All" machines are assigned to the borgbackup 'client' role
roles.client.tags = [ "all" ];
# But only one specific machine (backup-host) is assigned to the 'server' role
roles.server.machines."backup-host" = {};
};
}
```
## Sharing additional Nix configuration
Sometimes you need to add custom NixOS configuration alongside your clan
services. The `extraModules` option allows you to include additional NixOS
configuration that is applied for every machine assigned to that role.
There are multiple valid syntaxes for specifying modules:
```nix
inventory.instances = {
borgbackup = {
roles.client = {
# Direct module reference
extraModules = [ ../nixosModules/borgbackup.nix ];
# Or using self (needs to be json serializable)
# See next example, for a workaround.
extraModules = [ self.nixosModules.borgbackup ];
# Or inline module definition, (needs to be json compatible)
extraModules = [
{
# Your module configuration here
# ...
#
# If the module needs to contain non-serializable expressions:
imports = [ ./path/to/non-serializable.nix ];
}
];
};
};
}
```
## Picking a clanService
You can use services exposed by Clan's core module library, `clan-core`.
@@ -142,18 +167,19 @@ You can also author your own `clanService` modules.
You might expose your service module from your flake — this makes it easy for other people to also use your module in their clan.
---
______________________________________________________________________
## 💡 Tips for Working with clanServices
* You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
* Each service instance is isolated by its key in `inventory.instances`, allowing you to deploy multiple versions or roles of the same service type.
* Roles can target different machines or be scoped dynamically.
- You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
- Each service instance is isolated by its key in `inventory.instances`, allowing to deploy multiple versions or roles of the same service type.
- Roles can target different machines or be scoped dynamically.
---
______________________________________________________________________
## What's Next?
* [Author your own clanService →](../guides/services/community.md)
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
- [Author your own clanService →](../guides/services/community.md)
- [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->

View File

@@ -1,12 +1,15 @@
# Update Your Machines
# Update Machines
Clan CLI enables you to remotely update your machines over SSH. This requires setting up a target address for each target machine.
The Clan command line interface enables you to update machines remotely over SSH.
In this guide we will teach you how to set a `targetHost` in Nix,
and how to define a remote builder for your machine closures.
### Setting `targetHost`
In your Nix files, set the `targetHost` to the reachable IP address of your new machine. This eliminates the need to specify `--target-host` with every command.
## Setting `targetHost`
Set the machines `targetHost` to the reachable IP address of the new machine.
This eliminates the need to specify `--target-host` in CLI commands.
```{.nix title="clan.nix" hl_lines="9"}
{
@@ -23,15 +26,42 @@ inventory.machines = {
# [...]
}
```
The use of `root@` in the target address implies SSH access as the `root` user.
Ensure that the root login is secured and only used when necessary.
## Multiple Target Hosts
### Setting a Build Host
You can now experiment with a new interface that allows you to define multiple `targetHost` addresses for different VPNs. Learn more and try it out in our [networking guide](../networking.md).
If the machine does not have enough resources to run the NixOS evaluation or build itself,
it is also possible to specify a build host instead.
During an update, the cli will ssh into the build host and run `nixos-rebuild` from there.
## Updating Machine Configurations
Execute the following command to update the specified machine:
```bash
clan machines update jon
```
All machines can be updated simultaneously by omitting the machine name:
```bash
clan machines update
```
---
## Advanced Usage
The following options are only needed for special cases, such as limited resources, mixed environments, or private flakes.
### Setting `buildHost`
If the machine does not have enough resources to run the NixOS **evaluation** or **build** itself,
it is also possible to specify a `buildHost` instead.
During an update, clan will ssh into the `buildHost` and run `nixos-rebuild` from there.
!!! Note
The `buildHost` option should be set directly within your machines Nix configuration, **not** under `inventory.machines`.
```{.nix hl_lines="5" .no-copy}
@@ -45,7 +75,11 @@ buildClan {
};
```
You can also override the build host via the command line:
### Overriding configuration with CLI flags
`buildHost` / `targetHost`, and other network settings can be temporarily overridden for a single command:
For the full list of flags refer to the [Clan CLI](../../reference/cli/index.md)
```bash
# Build on a remote host
@@ -56,23 +90,9 @@ clan machines update jon --build-host local
```
!!! Note
Make sure that the CPU architecture is the same for the buildHost as for the targetHost.
Example:
If you want to deploy to a macOS machine, your architecture is an ARM64-Darwin, that means you need a second macOS machine to build it.
Make sure the CPU architecture of the `buildHost` matches that of the `targetHost`
### Updating Machine Configurations
Execute the following command to update the specified machine:
```bash
clan machines update jon
```
You can also update all configured machines simultaneously by omitting the machine name:
```bash
clan machines update
```
For example, if deploying to a macOS machine with an ARM64-Darwin architecture, you need a second macOS machine with the same architecture to build it.
### Excluding a machine from `clan machine update`
@@ -96,14 +116,15 @@ This is useful for machines that are not always online or are not part of the re
### Uploading Flake Inputs
When updating remote machines, flake inputs are usually fetched by the build host.
However, if your flake inputs require authentication (e.g., private repositories),
you can use the `--upload-inputs` flag to upload all inputs from your local machine:
However, if flake inputs require authentication (e.g., private repositories),
Use the `--upload-inputs` flag to upload all inputs from your local machine:
```bash
clan machines update jon --upload-inputs
```
This is particularly useful when:
- Your flake references private Git repositories
- Authentication credentials are only available on your local machine
- The flake references private Git repositories
- Authentication credentials are only available on local machine
- The build host doesn't have access to certain network resources

View File

@@ -254,7 +254,7 @@ The following table shows the migration status of each deprecated clanModule:
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
| `deltachat` | ❌ Removed | |
| `disk-id` | ❌ Removed | |
| `dyndns` | [Being Migrated](https://git.clan.lol/clan/clan-core/pulls/4390) | |
| `dyndns` | [Migrated](../../reference/clanServices/dyndns.md) | |
| `ergochat` | ❌ Removed | |
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
| `golem-provider` | ❌ Removed | |
@@ -263,18 +263,18 @@ The following table shows the migration status of each deprecated clanModule:
| `iwd` | ❌ Removed | Use [wifi service](../../reference/clanServices/wifi.md) instead |
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
| `localsend` | ❌ Removed | |
| `machine-id` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
| `machine-id` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `matrix-synapse` | ✅ [Migrated](../../reference/clanServices/matrix-synapse.md) | |
| `moonlight` | ❌ Removed | |
| `mumble` | ❌ Removed | |
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
| `nginx` | ❌ Removed | |
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
| `postgresql` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
| `postgresql` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | See [migration guide](../../reference/clanServices/users.md#migration-from-root-password-module) |
| `single-disk` | ❌ Removed | |
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
| `state-version` | ✅ [Migrated](../../reference/clanServices/state-version.md) | |
| `state-version` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `static-hosts` | ❌ Removed | |
| `sunshine` | ❌ Removed | |
| `syncthing-static-peers` | ❌ Removed | |

View File

@@ -255,11 +255,50 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}:
})
```
The benefit of this approach is that downstream users can override the value of `myClan` by using `mkForce` or other priority modifiers.
The benefit of this approach is that downstream users can override the value of
`myClan` by using `mkForce` or other priority modifiers.
## Example: A machine-type service
Users often have different types of machines. These could be any classification
you like, for example "servers" and "desktops". Having such distictions, allows
reusing parts of your configuration that should be appplied to a class of
machines. Since this is such a common pattern, here is how to write such a
service.
For this example the we have to roles: `server` and `desktop`. Additionally, we
can use the `perMachine` section to add configuration to all machines regardless
of their type.
```nix title="machine-type.nix"
{
_class = "clan.service";
manifest.name = "machine-type";
roles.server.perInstance.nixosModule = ./server.nix;
roles.desktop.perInstance.nixosModule = ./desktop.nix;
perMachine.nixosModule = {
# Configuration for all machines (any type)
};
}
```
In the inventory we the assign machines to a type, e.g. by using tags
```nix title="flake.nix"
instnaces.machine-type = {
module.input = "self";
module.name = "@pinpox/machine-type";
roles.desktop.tags.desktop = { };
roles.server.tags.server = { };
};
```
---
## Further
## Further Reading
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)

6
flake.lock generated
View File

@@ -99,11 +99,11 @@
},
"nixos-facter-modules": {
"locked": {
"lastModified": 1756109073,
"narHash": "sha256-5pjFEziluVwJ0Z50h9laKfWbDluXuA5ada05xb/QiV4=",
"lastModified": 1756291602,
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
"owner": "nix-community",
"repo": "nixos-facter-modules",
"rev": "a1042c81126d9c9314c1eb1a7b89ab4d81b5dea7",
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
"type": "github"
},
"original": {

View File

@@ -324,14 +324,13 @@ class Machine:
# Always run command with shell opts
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
proc = subprocess.run(
return subprocess.run(
self.nsenter_command(command),
timeout=timeout,
check=False,
stdout=subprocess.PIPE,
text=True,
)
return proc
def nested(
self,

View File

@@ -180,15 +180,15 @@ class CompositeLogger(AbstractLogger):
stack.enter_context(logger.nested(message, attributes))
yield
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
for logger in self.logger_list:
logger.info(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
for logger in self.logger_list:
logger.warning(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
for logger in self.logger_list:
logger.error(*args, **kwargs)
sys.exit(1)
@@ -245,13 +245,13 @@ class TerminalLogger(AbstractLogger):
toc = time.time()
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def print_serial_logs(self, enable: bool) -> None:
@@ -297,13 +297,13 @@ class XMLLogger(AbstractLogger):
self.xml.characters(message)
self.xml.endElement("line")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
self.log(*args, **kwargs)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:

View File

@@ -8,6 +8,10 @@
{
imports = lib.optional (_class == "nixos") (
lib.mkIf config.clan.core.enableRecommendedDefaults {
# Enable automatic state-version generation.
clan.core.settings.state-version.enable = true;
# Use systemd during boot as well except:
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
# - for containers we currently rely on the `stage-2` init script that sets up our /etc
@@ -37,6 +41,7 @@
};
config = lib.mkIf config.clan.core.enableRecommendedDefaults {
# This disables the HTML manual and `nixos-help` command but leaves
# `man configuration.nix`
documentation.doc.enable = lib.mkDefault false;

View File

@@ -9,28 +9,11 @@
clan = {
directory = ./.;
# Workaround until we can use nodes.server = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.server = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
clan.core.settings.state-version.enable = true;
}
];
};
machines.server = {
clan.core.settings.state-version.enable = true;
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.server = { };
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";

View File

@@ -16,6 +16,10 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
# Constants
NODE_ID_LENGTH = 10
NETWORK_ID_LENGTH = 16
class ClanError(Exception):
pass
@@ -55,8 +59,8 @@ class Identity:
def node_id(self) -> str:
nid = self.public.split(":")[0]
if len(nid) != 10:
msg = f"node_id must be 10 characters long, got {len(nid)}: {nid}"
if len(nid) != NODE_ID_LENGTH:
msg = f"node_id must be {NODE_ID_LENGTH} characters long, got {len(nid)}: {nid}"
raise ClanError(msg)
return nid
@@ -173,8 +177,8 @@ def create_identity() -> Identity:
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
if len(network_id) != 16:
msg = f"network_id must be 16 characters long, got '{network_id}'"
if len(network_id) != NETWORK_ID_LENGTH:
msg = f"network_id must be {NETWORK_ID_LENGTH} characters long, got '{network_id}'"
raise ClanError(msg)
nwid = int(network_id, 16)
node_id = int(identity.node_id(), 16)

5
nixosModules/clanCore/zerotier/genmoon.py Normal file → Executable file
View File

@@ -6,9 +6,12 @@ import sys
from pathlib import Path
from tempfile import NamedTemporaryFile
# Constants
REQUIRED_ARGS = 4
def main() -> None:
if len(sys.argv) != 4:
if len(sys.argv) != REQUIRED_ARGS:
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
sys.exit(1)
moon_json_path = sys.argv[1]

View File

@@ -5,7 +5,7 @@ from contextlib import ExitStack
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from clan_lib.api import ApiResponse
from clan_lib.api import ApiError, ApiResponse, ErrorDataClass
from clan_lib.api.tasks import WebThread
from clan_lib.async_run import set_current_thread_opkey, set_should_cancel
@@ -43,7 +43,7 @@ class ApiBridge(ABC):
def process_request(self, request: BackendRequest) -> None:
"""Process an API request through the middleware chain."""
from .middleware import MiddlewareContext
from .middleware import MiddlewareContext # noqa: PLC0415
with ExitStack() as stack:
context = MiddlewareContext(
@@ -59,7 +59,7 @@ class ApiBridge(ABC):
f"{middleware.__class__.__name__} => {request.method_name}",
)
middleware.process(context)
except Exception as e:
except Exception as e: # noqa: BLE001
# If middleware fails, handle error
self.send_api_error_response(
request.op_key or "unknown",
@@ -75,8 +75,6 @@ class ApiBridge(ABC):
location: list[str],
) -> None:
"""Send an error response."""
from clan_lib.api import ApiError, ErrorDataClass
error_data = ErrorDataClass(
op_key=op_key,
status="error",

View File

@@ -91,7 +91,6 @@ def get_system_file(
def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
global RESULT
RESULT[op_key] = data
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:

View File

@@ -94,10 +94,10 @@ class LoggingMiddleware(Middleware):
if self.handler:
self.handler.root_logger.removeHandler(self.handler.new_handler)
self.handler.new_handler.close()
if self.log_f:
self.log_f.close()
if self.original_ctx:
set_async_ctx(self.original_ctx)
if self.log_f:
self.log_f.close()
# Register the logging context manager
self.register_context_manager(context, LoggingContextManager(log_file))

View File

@@ -1,5 +1,6 @@
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
@@ -16,6 +17,7 @@ from clan_app.api.middleware import (
LoggingMiddleware,
MethodExecutionMiddleware,
)
from clan_app.deps.http.http_server import HttpApiServer
from clan_app.deps.webview.webview import Size, SizeHint, Webview
log = logging.getLogger(__name__)
@@ -64,8 +66,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
# Start HTTP API server if requested
http_server = None
if app_opts.http_api:
from clan_app.deps.http.http_server import HttpApiServer
openapi_file = os.getenv("OPENAPI_FILE", None)
swagger_dist = os.getenv("SWAGGER_UI_DIST", None)
@@ -95,8 +95,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
log.info("Press Ctrl+C to stop the server")
try:
# Keep the main thread alive
import time
while True:
time.sleep(1)
except KeyboardInterrupt:

View File

@@ -148,8 +148,8 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(file_data)
except Exception as e:
log.error(f"Error reading Swagger file: {e!s}")
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
log.exception("Error reading Swagger file")
self.send_error(500, "Internal Server Error")
def _get_swagger_file_path(self, rel_path: str) -> Path:
@@ -191,13 +191,13 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
return file_data
def do_OPTIONS(self) -> None: # noqa: N802
def do_OPTIONS(self) -> None:
"""Handle CORS preflight requests."""
self.send_response_only(200)
self._send_cors_headers()
self.end_headers()
def do_GET(self) -> None: # noqa: N802
def do_GET(self) -> None:
"""Handle GET requests."""
parsed_url = urlparse(self.path)
path = parsed_url.path
@@ -211,7 +211,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
else:
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
def do_POST(self) -> None: # noqa: N802
def do_POST(self) -> None:
"""Handle POST requests."""
parsed_url = urlparse(self.path)
path = parsed_url.path
@@ -252,7 +252,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
gen_op_key = str(uuid.uuid4())
try:
self._handle_api_request(method_name, request_data, gen_op_key)
except Exception as e:
except RuntimeError as e:
log.exception(f"Error processing API request {method_name}")
self.send_api_error_response(
gen_op_key,
@@ -264,10 +264,10 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
"""Read and parse the request body. Returns None if there was an error."""
try:
content_length = int(self.headers.get("Content-Length", 0))
if content_length > 0:
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
return {}
if content_length == 0:
return {}
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
self.send_api_error_response(
"post",
@@ -275,7 +275,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
["http_bridge", "POST", method_name],
)
return None
except Exception as e:
except (OSError, ValueError, UnicodeDecodeError) as e:
self.send_api_error_response(
"post",
f"Error reading request: {e!s}",
@@ -305,7 +305,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
op_key=op_key,
)
except Exception as e:
except (KeyError, TypeError, ValueError) as e:
self.send_api_error_response(
gen_op_key,
str(e),

View File

@@ -4,13 +4,11 @@ import json
import logging
import threading
import time
from unittest.mock import Mock
from urllib.request import Request, urlopen
import pytest
from clan_lib.api import MethodRegistry, tasks
from clan_lib.async_run import is_async_cancelled
from clan_lib.log_manager import LogManager
from clan_app.api.middleware import (
ArgumentParsingMiddleware,
@@ -53,31 +51,20 @@ def mock_api() -> MethodRegistry:
return api
@pytest.fixture
def mock_log_manager() -> Mock:
"""Create a mock log manager."""
log_manager = Mock(spec=LogManager)
log_manager.create_log_file.return_value.get_file_path.return_value = Mock()
log_manager.create_log_file.return_value.get_file_path.return_value.open.return_value = Mock()
return log_manager
@pytest.fixture
def http_bridge(
mock_api: MethodRegistry,
mock_log_manager: Mock,
) -> tuple[MethodRegistry, tuple]:
"""Create HTTP bridge dependencies for testing."""
middleware_chain = (
ArgumentParsingMiddleware(api=mock_api),
# LoggingMiddleware(log_manager=mock_log_manager),
MethodExecutionMiddleware(api=mock_api),
)
return mock_api, middleware_chain
@pytest.fixture
def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer:
def http_server(mock_api: MethodRegistry) -> HttpApiServer:
"""Create HTTP server with mock dependencies."""
server = HttpApiServer(
api=mock_api,
@@ -87,7 +74,6 @@ def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServ
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Bridge will be created automatically when accessed
@@ -114,7 +100,6 @@ class TestHttpBridge:
# The actual HTTP handling will be tested through the server integration tests
assert len(middleware_chain) == 2
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
# assert isinstance(middleware_chain[1], LoggingMiddleware)
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
@@ -151,14 +136,14 @@ class TestHttpApiServer:
try:
# Test root endpoint
response = urlopen("http://127.0.0.1:8081/") # noqa: S310
response = urlopen("http://127.0.0.1:8081/")
data: dict = json.loads(response.read().decode())
assert data["body"]["status"] == "success"
assert data["body"]["data"]["message"] == "Clan API Server"
assert data["body"]["data"]["version"] == "1.0.0"
# Test methods endpoint
response = urlopen("http://127.0.0.1:8081/api/methods") # noqa: S310
response = urlopen("http://127.0.0.1:8081/api/methods")
data = json.loads(response.read().decode())
assert data["body"]["status"] == "success"
assert "test_method" in data["body"]["data"]["methods"]
@@ -194,7 +179,7 @@ class TestHttpApiServer:
try:
# Test 404 error
res = urlopen("http://127.0.0.1:8081/nonexistent") # noqa: S310
res = urlopen("http://127.0.0.1:8081/nonexistent")
assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
@@ -259,7 +244,6 @@ class TestIntegration:
def test_full_request_flow(
self,
mock_api: MethodRegistry,
mock_log_manager: Mock,
) -> None:
"""Test complete request flow from server to bridge to middleware."""
server: HttpApiServer = HttpApiServer(
@@ -270,7 +254,6 @@ class TestIntegration:
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Bridge will be created automatically when accessed
@@ -306,7 +289,6 @@ class TestIntegration:
def test_blocking_task(
self,
mock_api: MethodRegistry,
mock_log_manager: Mock,
) -> None:
shared_threads: dict[str, tasks.WebThread] = {}
tasks.BAKEND_THREADS = shared_threads
@@ -321,7 +303,6 @@ class TestIntegration:
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Start server

View File

@@ -12,12 +12,11 @@ from clan_lib.api import MethodRegistry, message_queue
from clan_lib.api.tasks import WebThread
from ._webview_ffi import _encode_c_string, _webview_lib
from .webview_bridge import WebviewBridge
if TYPE_CHECKING:
from clan_app.api.middleware import Middleware
from .webview_bridge import WebviewBridge
log = logging.getLogger(__name__)
@@ -49,7 +48,7 @@ class Webview:
shared_threads: dict[str, WebThread] | None = None
# initialized later
_bridge: "WebviewBridge | None" = None
_bridge: WebviewBridge | None = None
_handle: Any | None = None
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
_middleware: list["Middleware"] = field(default_factory=list)
@@ -81,7 +80,7 @@ class Webview:
msg = message_queue.get() # Blocks until available
js_code = f"window.notifyBus({json.dumps(msg)});"
self.eval(js_code)
except Exception as e:
except (json.JSONDecodeError, RuntimeError, AttributeError) as e:
print("Bridge notify error:", e)
sleep(0.01) # avoid busy loop
@@ -132,10 +131,8 @@ class Webview:
self._middleware.append(middleware)
def create_bridge(self) -> "WebviewBridge":
def create_bridge(self) -> WebviewBridge:
"""Create and initialize the WebviewBridge with current middleware."""
from .webview_bridge import WebviewBridge
# Use shared_threads if provided, otherwise let WebviewBridge use its default
if self.shared_threads is not None:
bridge = WebviewBridge(
@@ -211,7 +208,7 @@ class Webview:
try:
result = callback(*args)
success = True
except Exception as e:
except Exception as e: # noqa: BLE001
result = str(e)
success = False
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))

View File

@@ -8,8 +8,6 @@ from clan_lib.api.tasks import WebThread
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
from .webview import FuncStatus
if TYPE_CHECKING:
from .webview import Webview
@@ -32,6 +30,9 @@ class WebviewBridge(ApiBridge):
)
log.debug(f"Sending response: {serialized}")
# Import FuncStatus locally to avoid circular import
from .webview import FuncStatus # noqa: PLC0415
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
def handle_webview_call(

View File

@@ -5,10 +5,6 @@
@apply pl-3;
}
&.hasIcon svg.icon {
@apply relative top-0.5;
}
&.hasDismiss {
@apply pr-3;
}
@@ -35,6 +31,10 @@
&.noPadding {
@apply p-0;
}
svg {
@apply relative top-0.5;
}
}
.alertContent {

View File

@@ -68,6 +68,7 @@ export const Button = (props: ButtonProps) => {
},
)}
onClick={props.onClick}
disabled={props.disabled || props.loading}
{...other}
>
<Loader
@@ -90,7 +91,6 @@ export const Button = (props: ButtonProps) => {
<Typography
class="label"
hierarchy="label"
family="mono"
size={local.size || "default"}
inverted={local.hierarchy === "primary"}
weight="bold"

View File

@@ -164,17 +164,26 @@ export const MachineTags = (props: MachineTagsProps) => {
<For each={state.selectedOptions()}>
{(option) => (
<Tag
label={option.value}
inverted={props.inverted}
action={
option.disabled || props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => state.remove(option),
}
interactive={
!(option.disabled || props.disabled || props.readOnly)
}
/>
icon={({ inverted }) =>
option.disabled ||
props.disabled ||
props.readOnly ? undefined : (
<Icon
role="button"
icon={"Close"}
size="0.5rem"
inverted={inverted}
onClick={() => state.remove(option)}
/>
)
}
>
{option.value}
</Tag>
)}
</For>
<Show when={!props.readOnly}>

View File

@@ -25,7 +25,7 @@
.modal_body {
overflow-y: auto;
@apply rounded-b-md p-6 pt-4 bg-def-1 flex-grow;
@apply rounded-b-md p-4 pt-4 bg-def-1 flex-grow;
&[data-no-padding] {
@apply p-0;

View File

@@ -2,20 +2,33 @@ import Icon from "../Icon/Icon";
import { Button } from "../Button/Button";
import styles from "./Search.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { createMemo, createSignal, For, JSX } from "solid-js";
import {
createEffect,
createMemo,
createSignal,
For,
JSX,
Match,
Switch,
} from "solid-js";
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import cx from "classnames";
import { Loader } from "../Loader/Loader";
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
export interface ItemRenderOptions {
selected: boolean;
disabled: boolean;
}
export interface SearchMultipleProps<T> {
values: T[]; // controlled values
onChange: (values: T[]) => void;
options: T[];
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
@@ -23,12 +36,17 @@ export interface SearchMultipleProps<T> {
placeholder?: string;
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
height: string; // e.g. '14.5rem'
headerClass?: string;
headerChildren?: JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
divider?: boolean;
}
export function SearchMultiple<T extends Option>(
props: SearchMultipleProps<T>,
) {
// Controlled input value, to allow resetting the input itself
const [values, setValues] = createSignal<T[]>(props.initialValues || []);
// const [values, setValues] = createSignal<T[]>(props.initialValues || []);
const [inputValue, setInputValue] = createSignal<string>("");
let inputEl: HTMLInputElement;
@@ -54,30 +72,32 @@ export function SearchMultiple<T extends Option>(
return item?.rawValue?.value || `item-${index}`;
},
estimateSize: () => 42,
gap: 6,
gap: 0,
overscan: 5,
...props.virtualizerOptions,
});
return newVirtualizer;
});
createEffect(() => {
console.log("multi values:", props.values);
});
return (
<Combobox<T>
multiple
value={values()}
value={props.values}
onChange={(values) => {
setValues(() => values);
// setInputValue(value ? value.label : "");
// setValues(() => values);
console.log("onChange", values);
props.onChange(values);
}}
class={styles.searchContainer}
style={{ "--container-height": props.height }}
placement="bottom-start"
options={props.options}
optionValue="value"
optionTextValue="label"
optionLabel="label"
optionDisabled={"disabled"}
sameWidth={true}
open={true}
gutter={7}
@@ -89,69 +109,78 @@ export function SearchMultiple<T extends Option>(
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={props.placeholder}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
<>
{props.headerChildren}
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={props.placeholder}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
</>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content
class={styles.searchContent}
tabIndex={-1}
style={{ "--container-height": props.height }}
>
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: "100%",
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
class={styles.listbox}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
@@ -169,11 +198,16 @@ export function SearchMultiple<T extends Option>(
return null;
}
const isSelected = () =>
values().some((v) => v.value === item.rawValue.value);
props.values.some(
(v) => v.value === item.rawValue.value,
);
return (
<Combobox.Item
item={item}
class={styles.searchItem}
class={cx(
styles.searchItem,
props.divider && styles.hasDivider,
)}
style={{
position: "absolute",
top: 0,
@@ -185,17 +219,19 @@ export function SearchMultiple<T extends Option>(
>
{props.renderItem(item.rawValue, {
selected: isSelected(),
disabled: item.disabled,
})}
</Combobox.Item>
);
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
{/* </Combobox.Content> */}
</Combobox>
);
}

View File

@@ -29,7 +29,7 @@
}
.searchHeader {
@apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50;
@apply flex gap-2 items-center p-2 rounded-t-md z-50;
@apply px-3 pt-3 pb-2;
}
@@ -42,18 +42,33 @@
}
.searchItem {
&[data-highlighted],
&:focus,
&:focus-visible,
&:hover {
@apply bg-inv-acc-2;
@apply flex flex-col justify-center overflow-hidden;
&.hasDivider {
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
}
&:active {
@apply bg-inv-acc-3;
/* Next element is hovered */
&:has(+ &:hover) {
box-shadow: unset;
}
@apply flex flex-col justify-center;
&:not([aria-disabled="true"])[data-highlighted],
&:not([aria-disabled="true"]):focus,
&:not([aria-disabled="true"]):focus-visible,
&:not([aria-disabled="true"]):hover {
@apply bg-inv-acc-2 rounded-md;
box-shadow: unset;
}
&:not([aria-disabled="true"]):active {
@apply bg-inv-acc-3 rounded-md;
box-shadow: unset;
}
&[aria-disabled="true"] {
@apply cursor-not-allowed;
}
}
.searchContainer {
@@ -61,16 +76,14 @@
@apply rounded-lg;
height: var(--container-height, 14.5rem);
border: 1px solid #2b4647;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%),
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%,
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
box-shadow:
@@ -78,10 +91,8 @@
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.searchContent {
@apply px-3;
height: var(--container-height, 14.5rem);
padding-bottom: 4rem;
.listbox {
@apply px-3 pt-3.5;
}
@keyframes contentHide {

View File

@@ -9,7 +9,7 @@ import {
SearchMultiple,
SearchMultipleProps,
} from "./MultipleSearch";
import { JSX, Show } from "solid-js";
import { Show } from "solid-js";
const meta = {
title: "Components/Search",
@@ -55,8 +55,8 @@ function generateModules(count: number): Module[] {
modules.push({
value: `lolcat/module-${i + 1}`,
label: `Module ${i + 1}`,
description: `${greek[i % greek.length]}#${i + 1}`,
input: "lolcat",
description: `${greek[i % greek.length]}#${i + 1} this is a very long description to test text wrapping in the search component`,
input: "lolcat-flake-part-from-nixpkgs-via-nix-via-clan-flake",
});
}
@@ -72,12 +72,13 @@ export interface Module {
export const Default: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
options: generateModules(1000),
renderItem: (item: Module) => {
return (
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
<div class="flex size-8 items-center justify-center rounded-md bg-white">
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
@@ -94,8 +95,12 @@ export const Default: Story = {
inverted
class="flex justify-between"
>
<span>{item.description}</span>
<span>by {item.input}</span>
<span class="inline-block max-w-72 truncate align-middle">
{item.description}
</span>
<span class="inline-block max-w-20 truncate align-middle">
by {item.input}
</span>
</Typography>
</div>
</div>
@@ -104,7 +109,7 @@ export const Default: Story = {
},
render: (args: SearchProps<Module>) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<div class="fixed bottom-10 left-1/2 mb-2 w-[30rem] -translate-x-1/2">
<Search<Module>
{...args}
onChange={(module) => {
@@ -117,32 +122,43 @@ export const Default: Story = {
},
};
export const Loading: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
loading: true,
options: [],
renderItem: () => <span></span>,
},
render: (args: SearchProps<Module>) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<Search<Module>
{...args}
onChange={(module) => {
// Go to the module configuration
}}
/>
</div>
);
},
};
type MachineOrTag =
| {
value: string;
label: string;
type: "machine";
disabled?: boolean;
}
| {
members: string[];
value: string;
label: string;
disabled?: boolean;
type: "tag";
};
interface WrapIfProps {
condition: boolean;
wrapper: (children: JSX.Element) => JSX.Element;
children: JSX.Element;
}
const WrapIf = (props: WrapIfProps) => {
if (props.condition) {
return props.wrapper(props.children);
} else {
return props.children;
}
};
const machinesAndTags: MachineOrTag[] = [
{ value: "machine-1", label: "Machine 1", type: "machine" },
{ value: "machine-2", label: "Machine 2", type: "machine" },
@@ -183,7 +199,13 @@ export const Multiple: Story = {
</Show>
</Combobox.ItemIndicator>
<Combobox.ItemLabel class="flex items-center gap-2">
<Typography hierarchy="body" size="s" weight="medium" inverted>
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
color={opts.disabled ? "quaternary" : "primary"}
>
{item.label}
</Typography>
<Show when={item.type === "tag" && item}>
@@ -216,6 +238,7 @@ export const Multiple: Story = {
<div class="absolute bottom-1/3 w-3/4 px-3">
<SearchMultiple<MachineOrTag>
{...args}
divider
height="20rem"
virtualizerOptions={{
estimateSize: () => 38,

View File

@@ -2,20 +2,29 @@ import Icon from "../Icon/Icon";
import { Button } from "../Button/Button";
import styles from "./Search.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { createMemo, createSignal, For, JSX } from "solid-js";
import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
import { createVirtualizer } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import { Loader } from "../Loader/Loader";
import cx from "classnames";
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
export interface SearchProps<T> {
onChange: (value: T | null) => void;
options: T[];
renderItem: (item: T) => JSX.Element;
renderItem: (item: T, opts: { disabled: boolean }) => JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
headerClass?: string;
height: string; // e.g. '14.5rem'
divider?: boolean;
}
export function Search<T extends Option>(props: SearchProps<T>) {
// Controlled input value, to allow resetting the input itself
const [value, setValue] = createSignal<T | null>(null);
@@ -59,13 +68,14 @@ export function Search<T extends Option>(props: SearchProps<T>) {
setInputValue(value ? value.label : "");
props.onChange(value);
}}
class={styles.searchContainer}
class={cx(styles.searchContainer, props.divider && styles.hasDivider)}
placement="bottom-start"
options={props.options}
optionValue="value"
optionTextValue="label"
optionLabel="label"
placeholder="Search a service"
optionDisabled={"disabled"}
sameWidth={true}
open={true}
gutter={7}
@@ -77,7 +87,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
@@ -111,31 +123,39 @@ export function Search<T extends Option>(props: SearchProps<T>) {
</div>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: "100%",
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
class={styles.listbox}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
@@ -165,17 +185,19 @@ export function Search<T extends Option>(props: SearchProps<T>) {
transform: `translateY(${virtualRow.start}px)`,
}}
>
{props.renderItem(item.rawValue)}
{props.renderItem(item.rawValue, {
disabled: item.disabled,
})}
</Combobox.Item>
);
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
</Combobox>
);
}

View File

@@ -0,0 +1,20 @@
.trigger {
@apply rounded-md bg-inv-4 w-full min-h-11;
&:focus,
&:hover {
@apply outline outline-def-1 outline-1;
background:
linear-gradient(
90deg,
var(--clr-bg-inv-acc-3, #2c4347) 0%,
var(--clr-bg-inv-acc-2, #4f747a) 100%
),
var(--clr-bg-inv-4, #203637);
box-shadow: 0 0 0 2px var(--clr-bg-inv-acc-4, #162324) inset;
}
&.open {
@apply bg-inv-acc-4;
}
}

View File

@@ -0,0 +1,62 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { TagSelect, TagSelectProps } from "./TagSelect";
import { Tag } from "../Tag/Tag";
import Icon from "../Icon/Icon";
import { createSignal } from "solid-js";
const meta = {
title: "Components/Custom/SelectStepper",
component: TagSelect,
} satisfies Meta<TagSelectProps<string>>;
export default meta;
interface Item {
value: string;
label: string;
}
type Story = StoryObj<TagSelectProps<Item>>;
const Item = (item: Item) => (
<Tag
inverted
icon={(tag) => (
<Icon icon={"Machine"} size="0.5rem" inverted={tag.inverted} />
)}
>
{item.label}
</Tag>
);
export const Default: Story = {
args: {
renderItem: Item,
label: "Peer",
options: [
{ value: "foo", label: "Foo" },
{ value: "bar", label: "Bar" },
{ value: "baz", label: "Baz" },
{ value: "qux", label: "Qux" },
{ value: "quux", label: "Quux" },
{ value: "corge", label: "Corge" },
{ value: "grault", label: "Grault" },
],
} satisfies Partial<TagSelectProps<Item>>,
render: (args: TagSelectProps<Item>) => {
const [state, setState] = createSignal<Item[]>([]);
return (
<TagSelect<Item>
{...args}
values={state()}
onClick={() => {
console.log("Clicked, current values:");
setState(() => [
{ value: "baz", label: "Baz" },
{ value: "qux", label: "Qux" },
]);
}}
/>
);
},
};

View File

@@ -0,0 +1,85 @@
import Icon from "../Icon/Icon";
import { Typography } from "../Typography/Typography";
import { For, JSX, Show } from "solid-js";
import styles from "./TagSelect.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { Button } from "../Button/Button";
// Base props common to both modes
export interface TagSelectProps<T> {
onClick: () => void;
label: string;
values: T[];
options: T[];
renderItem: (item: T) => JSX.Element;
}
/**
* Shallowly interactive field for selecting multiple tags / machines.
* It does only handle click and focus interactions
* Displays the selected items as tags
*/
export function TagSelect<T extends { value: unknown }>(
props: TagSelectProps<T>,
) {
const optionValue = "value";
return (
<div class="flex flex-col gap-1.5">
<div class="flex w-full items-center gap-2 px-1.5 py-0">
<Typography
hierarchy="label"
weight="medium"
class="flex gap-2 uppercase"
size="xs"
inverted
color="secondary"
>
{props.label}
</Typography>
<Icon icon="Info" color="tertiary" inverted size={11} />
<Button
icon="Settings"
hierarchy="primary"
ghost
class="ml-auto"
size="xs"
/>
</div>
<Combobox<T>
multiple
optionValue={optionValue}
value={props.values}
options={props.options}
allowsEmptyCollection
class="w-full"
>
<Combobox.Control<T> aria-label="Fruits">
{(state) => {
return (
<Combobox.Trigger
tabIndex={1}
class={styles.trigger}
onClick={props.onClick}
>
<div class="flex flex-wrap items-center gap-2 px-2 py-3">
<Icon icon="Search" color="quaternary" inverted />
<Show when={state.selectedOptions().length === 0}>
<Typography
color="tertiary"
inverted
hierarchy="body"
size="s"
>
Select
</Typography>
</Show>
<For each={state.selectedOptions()}>{props.renderItem}</For>
</div>
</Combobox.Trigger>
);
}}
</Combobox.Control>
</Combobox>
</div>
);
}

View File

@@ -1,7 +1,3 @@
.sidebar {
@apply w-60 border-none z-10;
.body {
@apply pt-4 pb-3 px-2;
}
@apply w-60 border-none z-10 h-full flex flex-col;
}

View File

@@ -2,6 +2,7 @@ import styles from "./Sidebar.module.css";
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
import cx from "classnames";
import { splitProps } from "solid-js";
export interface LinkProps {
path: string;
@@ -19,10 +20,12 @@ export interface SidebarProps {
}
export const Sidebar = (props: SidebarProps) => {
const [bodyProps] = splitProps(props, ["staticSections"]);
return (
<div class={cx(styles.sidebar, props.class)}>
<SidebarHeader />
<SidebarBody class={cx(styles.body)} {...props} />
<SidebarBody {...bodyProps} />
</div>
);
};

View File

@@ -1,10 +1,10 @@
div.sidebar-pane {
@apply border-none z-10;
@apply flex flex-col border-none z-20 h-full;
animation: sidebarPaneShow 250ms ease-in forwards;
&.open {
@apply w-60;
@apply w-72;
}
&.closing {
@@ -90,7 +90,7 @@ div.sidebar-pane {
@apply opacity-100;
}
100% {
@apply w-60;
@apply w-72;
}
}

View File

@@ -1,5 +1,5 @@
div.sidebar-section {
@apply flex flex-col gap-2 w-full h-fit;
@apply flex flex-col gap-2 w-full h-full;
& > div.header {
@apply flex items-center justify-between px-1.5;

View File

@@ -17,7 +17,6 @@ export const SidebarSection = (props: SidebarSectionProps) => {
hierarchy="label"
size="xs"
family="mono"
weight="light"
transform="uppercase"
color="tertiary"
inverted={true}

View File

@@ -73,7 +73,6 @@ export function SidebarSectionForm<
hierarchy="label"
size="xs"
family="mono"
weight="light"
transform="uppercase"
color="tertiary"
inverted

View File

@@ -19,7 +19,9 @@ span.tag {
&.has-action {
@apply pr-1.5;
}
&.is-interactive {
&:hover {
@apply bg-def-acc-3;
}

View File

@@ -1,6 +1,7 @@
import { Tag, TagProps } from "@/src/components/Tag/Tag";
import { Meta, type StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { expect, fn } from "storybook/test";
import { fn } from "storybook/test";
import Icon from "../Icon/Icon";
const meta: Meta<TagProps> = {
title: "Components/Tag",
@@ -13,27 +14,44 @@ type Story = StoryObj<TagProps>;
export const Default: Story = {
args: {
label: "Label",
children: "Label",
},
};
const IconAction = ({
inverted,
handleActionClick,
}: {
inverted: boolean;
handleActionClick: () => void;
}) => (
<Icon
role="button"
icon={"Close"}
size="0.5rem"
onClick={() => {
console.log("icon clicked");
handleActionClick();
fn();
}}
inverted={inverted}
/>
);
export const WithAction: Story = {
args: {
...Default.args,
action: {
icon: "Close",
onClick: fn(),
},
icon: IconAction,
interactive: true,
},
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
await userEvent.click(canvas.getByRole("button"));
await expect(args.action.onClick).toHaveBeenCalled();
// await expect(args.icon.onClick).toHaveBeenCalled();
},
};
export const Inverted: Story = {
args: {
label: "Label",
children: "Label",
inverted: true,
},
};

View File

@@ -2,18 +2,19 @@ import "./Tag.css";
import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
import { createSignal, Show } from "solid-js";
import Icon, { IconVariant } from "../Icon/Icon";
import { createSignal, JSX } from "solid-js";
export interface TagAction {
icon: IconVariant;
onClick: () => void;
interface IconActionProps {
inverted: boolean;
handleActionClick: () => void;
}
export interface TagProps {
label: string;
action?: TagAction;
export interface TagProps extends JSX.HTMLAttributes<HTMLSpanElement> {
children?: JSX.Element;
icon?: (state: IconActionProps) => JSX.Element;
inverted?: boolean;
interactive?: boolean;
class?: string;
}
export const Tag = (props: TagProps) => {
@@ -23,7 +24,6 @@ export const Tag = (props: TagProps) => {
const handleActionClick = () => {
setIsActive(true);
props.action?.onClick();
setTimeout(() => setIsActive(false), 150);
};
@@ -32,23 +32,18 @@ export const Tag = (props: TagProps) => {
class={cx("tag", {
inverted: inverted(),
active: isActive(),
"has-action": props.action,
"has-icon": props.icon,
"is-interactive": props.interactive,
class: props.class,
})}
aria-label={props.label}
aria-readonly={!props.action}
>
<Typography hierarchy="label" size="xs" inverted={inverted()}>
{props.label}
{props.children}
</Typography>
<Show when={props.action}>
<Icon
role="button"
icon={props.action!.icon}
size="0.5rem"
inverted={inverted()}
onClick={handleActionClick}
/>
</Show>
{props.icon?.({
inverted: inverted(),
handleActionClick,
})}
</span>
);
};

View File

@@ -15,7 +15,7 @@ export const TagGroup = (props: TagGroupProps) => {
return (
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
<For each={props.labels}>
{(label) => <Tag label={label} inverted={inverted()} />}
{(label) => <Tag inverted={inverted()}>{label}</Tag>}
</For>
</div>
);

View File

@@ -1,9 +1,5 @@
/* Body */
.typography {
&.weight-light {
font-weight: 300;
}
&.weight-normal {
font-weight: 400;
}
@@ -71,6 +67,12 @@
line-height: normal;
letter-spacing: 0.008125rem;
}
&.size-xxs {
font-size: 0.75rem;
line-height: 1;
/* letter-spacing: 0.008125rem; */
}
}
&.family-mono {
@@ -93,6 +95,11 @@
line-height: normal;
letter-spacing: normal;
}
&.size-xxs {
font-size: 0.75rem;
line-height: 1;
/* letter-spacing: 0.008125rem; */
}
}
}

View File

@@ -6,7 +6,7 @@ import { Color, fgClass } from "@/src/components/colors";
export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
export type Weight = "normal" | "medium" | "bold" | "light";
export type Weight = "normal" | "medium" | "bold";
export type Family = "regular" | "condensed" | "mono";
export type Transform = "uppercase" | "lowercase" | "capitalize";
@@ -87,7 +87,6 @@ const weightMap: Record<Weight, string> = {
normal: "weight-normal",
medium: "weight-medium",
bold: "weight-bold",
light: "weight-light",
};
interface _TypographyProps<H extends Hierarchy> {

View File

@@ -42,6 +42,7 @@ export const DefaultQueryClient = new QueryClient({
},
});
export type MachinesQuery = ReturnType<typeof useMachinesQuery>;
export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
@@ -117,11 +118,31 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
}));
};
export type TagsQuery = ReturnType<typeof useTags>;
export const useTags = (clanURI: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanURI), "tags"],
queryFn: async () => {
const apiCall = client.fetch("list_tags", {
flake: {
identifier: clanURI,
},
});
const result = await apiCall.result;
if (result.status === "error") {
throw new Error("Error fetching tags: " + result.errors[0].message);
}
return result.data;
},
}));
};
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
refetchInterval: 1000 * 60, // poll every 60 seconds
queryFn: async () => {
const apiCall = client.fetch("get_machine_state", {
machine: {
@@ -434,12 +455,14 @@ export const useMachineGenerators = (
],
queryFn: async () => {
const call = client.fetch("get_generators", {
machine: {
name: machineName,
flake: {
identifier: clanUri,
machines: [
{
name: machineName,
flake: {
identifier: clanUri,
},
},
},
],
full_closure: true, // TODO: Make this configurable
// TODO: Make this configurable
include_previous_values: true,
@@ -456,3 +479,53 @@ export const useMachineGenerators = (
},
}));
};
export type ServiceModulesQuery = ReturnType<typeof useServiceModules>;
export type ServiceModules = SuccessData<"list_service_modules">;
export const useServiceModules = (clanUri: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
queryFn: async () => {
const call = client.fetch("list_service_modules", {
flake: {
identifier: clanUri,
},
});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
export type ServiceInstances = SuccessData<"list_service_instances">;
export const useServiceInstances = (clanUri: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
queryFn: async () => {
const call = client.fetch("list_service_instances", {
flake: {
identifier: clanUri,
},
});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};

View File

@@ -0,0 +1,15 @@
import { onCleanup } from "solid-js";
export function useClickOutside(
el: () => HTMLElement | undefined,
handler: (e: MouseEvent) => void,
) {
const listener = (e: MouseEvent) => {
const element = el();
if (element && !element.contains(e.target as Node)) {
handler(e);
}
};
document.addEventListener("mousedown", listener);
onCleanup(() => document.removeEventListener("mousedown", listener));
}

View File

@@ -10,6 +10,7 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
import { ApiClientProvider } from "./hooks/ApiClient";
import { callApi } from "./hooks/api";
import { DefaultQueryClient } from "@/src/hooks/queries";
import { Toaster } from "solid-toast";
const root = document.getElementById("app");
@@ -25,6 +26,8 @@ if (import.meta.env.DEV) {
render(
() => (
<ApiClientProvider client={{ fetch: callApi }}>
{/* Temporary solution */}
<Toaster toastOptions={{}} />
<QueryClientProvider client={DefaultQueryClient}>
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
<Router root={Layout}>{Routes}</Router>

View File

@@ -199,7 +199,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
disabled={removeDisabled()}
onClick={onRemove}
>
Remove
Remove Clan
</Button>
</div>
</div>

View File

@@ -7,9 +7,38 @@
@apply min-w-96;
}
.sidebar {
@apply absolute left-4 top-10 w-60;
@apply min-h-96;
.sidebarContainer {
@apply absolute left-4 top-4 w-60 z-10;
height: calc(100vh - 2rem);
height: calc(100vh - 8rem);
animation: sidebarNoMachine 250ms ease-in-out;
&.machineSelected {
@apply top-16;
height: calc(100vh - 8rem);
animation: sidebarMachine 250ms ease-in-out;
}
}
@keyframes sidebarNoMachine {
0% {
@apply top-16;
height: calc(100vh - 8rem);
}
100% {
@apply top-4;
height: calc(100vh - 2rem);
}
}
@keyframes sidebarMachine {
0% {
@apply top-4;
height: calc(100vh - 2rem);
}
100% {
@apply top-16;
height: calc(100vh - 8rem);
}
}

View File

@@ -14,8 +14,9 @@ import {
buildMachinePath,
maybeUseMachineName,
useClanURI,
useMachineName,
} from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
import {
ClanDetails,
MachinesQueryResult,
@@ -36,6 +37,12 @@ import { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
import {
ServiceWorkflow,
SubmitServiceHandler,
} from "@/src/workflows/Service/Service";
import { useApiClient } from "@/src/hooks/ApiClient";
import toast from "solid-toast";
interface ClanContextProps {
clanURI: string;
@@ -114,7 +121,13 @@ export const Clan: Component<RouteSectionProps> = (props) => {
)
}
>
<Sidebar class={cx(styles.sidebar)} />
<div
class={cx(styles.sidebarContainer, {
[styles.machineSelected]: useMachineName(),
})}
>
<Sidebar />
</div>
{props.children}
<ClanSceneController {...props} />
</ClanContext.Provider>
@@ -179,7 +192,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
const navigate = useNavigate();
const [dialogHandlers, setDialogHandlers] = createSignal<{
const [showService, setShowService] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [currentPromise, setCurrentPromise] = createSignal<{
resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void;
} | null>(null);
@@ -187,7 +203,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowModal(true);
setDialogHandlers({ resolve, reject });
setCurrentPromise({ resolve, reject });
});
};
const onAddService = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowService((v) => !v);
console.log("setting current promise");
setCurrentPromise({ resolve, reject });
});
};
@@ -217,8 +241,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
return { id: values.name };
};
const [showModal, setShowModal] = createSignal(false);
const [loadingError, setLoadingError] = createSignal<
{ title: string; description: string } | undefined
>();
@@ -265,6 +287,47 @@ const ClanSceneController = (props: RouteSectionProps) => {
}),
);
const client = useApiClient();
const handleSubmitService: SubmitServiceHandler = async (
instance,
action,
) => {
console.log(action, "Instance", instance);
if (action !== "create") {
toast.error("Only creating new services is supported");
return;
}
const call = client.fetch("create_service_instance", {
flake: {
identifier: ctx.clanURI,
},
module_ref: instance.module,
roles: instance.roles,
});
const result = await call.result;
if (result.status === "error") {
toast.error("Error creating service instance");
console.error("Error creating service instance", result.errors);
}
toast.success("Created");
//
currentPromise()?.resolve({ id: "0" });
setShowService(false);
};
createEffect(
on(worldMode, (mode) => {
if (mode === "service") {
setShowService(true);
} else {
// todo: request close instead of force close
setShowService(false);
}
}),
);
return (
<>
<Show when={loadingError()}>
@@ -274,15 +337,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
<MockCreateMachine
onClose={() => {
setShowModal(false);
dialogHandlers()?.reject(new Error("User cancelled"));
currentPromise()?.reject(new Error("User cancelled"));
}}
onSubmit={async (values) => {
try {
const result = await sendCreate(values);
dialogHandlers()?.resolve(result);
currentPromise()?.resolve(result);
setShowModal(false);
} catch (err) {
dialogHandlers()?.reject(err);
currentPromise()?.reject(err);
setShowModal(false);
}
}}
@@ -301,22 +364,39 @@ const ClanSceneController = (props: RouteSectionProps) => {
onSelect={onMachineSelect}
isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery}
toolbarPopup={
<Show when={showService()}>
<ServiceWorkflow
handleSubmit={handleSubmitService}
onClose={() => {
setShowService(false);
setWorldMode("default");
currentPromise()?.resolve({ id: "0" });
}}
/>
</Show>
}
onCreate={onCreate}
clanURI={ctx.clanURI}
sceneStore={() => store.sceneData?.[ctx.clanURI]}
setMachinePos={(machineId: string, pos: [number, number]) => {
setMachinePos={(machineId: string, pos: [number, number] | null) => {
console.log("calling setStore", machineId, pos);
setStore(
produce((s) => {
if (!s.sceneData) {
s.sceneData = {};
}
if (!s.sceneData[ctx.clanURI]) {
s.sceneData[ctx.clanURI] = {};
}
if (!s.sceneData[ctx.clanURI][machineId]) {
s.sceneData[ctx.clanURI][machineId] = { position: pos };
if (!s.sceneData) s.sceneData = {};
if (!s.sceneData[ctx.clanURI]) s.sceneData[ctx.clanURI] = {};
if (pos === null) {
// Remove the machine entry if pos is null
Reflect.deleteProperty(s.sceneData[ctx.clanURI], machineId);
if (Object.keys(s.sceneData[ctx.clanURI]).length === 0) {
Reflect.deleteProperty(s.sceneData, ctx.clanURI);
}
} else {
s.sceneData[ctx.clanURI][machineId].position = pos;
// Set or update the machine position
s.sceneData[ctx.clanURI][machineId] = { position: pos };
}
}),
);

View File

@@ -1,6 +1,5 @@
.sidebarPane {
@apply absolute left-[16.5rem] top-12 w-64;
@apply min-h-96;
.sidebarPaneContainer {
@apply absolute left-[16.5rem] top-4 w-64 z-20;
height: calc(100vh - 10rem);
height: calc(100vh - 2rem);
}

View File

@@ -9,7 +9,6 @@ import { callApi } from "@/src/hooks/api";
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
import cx from "classnames";
import styles from "./Machine.module.css";
export const Machine = (props: RouteSectionProps) => {
@@ -52,18 +51,19 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return (
<SidebarPane
class={cx(styles.sidebarPane)}
title={machineName}
onClose={onClose}
subHeader={() => (
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
)}
>
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
<div class={styles.sidebarPaneContainer}>
<SidebarPane
title={machineName}
onClose={onClose}
subHeader={() => (
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
)}
>
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
</div>
);
};

View File

@@ -163,7 +163,7 @@ const welcome = (props: {
loading={loading()}
onClick={selectFolder}
>
Select folder
Select existing Clan
</Button>
</div>
);

View File

@@ -5,12 +5,13 @@ import { SceneData } from "../stores/clan";
import { MachinesQueryResult } from "../hooks/queries";
import { ObjectRegistry } from "./ObjectRegistry";
import { renderLoop } from "./RenderLoop";
import { highlightGroups } from "./highlightStore";
function keyFromPos(pos: [number, number]): string {
return `${pos[0]},${pos[1]}`;
}
const CUBE_SPACING = 2;
const CUBE_SPACING = 1;
export class MachineManager {
public machines = new Map<string, MachineRepr>();
@@ -25,50 +26,72 @@ export class MachineManager {
machinePositionsSignal: Accessor<SceneData>,
machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>,
setMachinePos: (id: string, position: [number, number]) => void,
setMachinePos: (id: string, position: [number, number] | null) => void,
) {
this.machinePositionsSignal = machinePositionsSignal;
this.disposeRoot = createRoot((disposeEffects) => {
createEffect(() => {
const machines = machinePositionsSignal();
Object.entries(machines).forEach(([id, data]) => {
const machineRepr = new MachineRepr(
scene,
registry,
new THREE.Vector2(data.position[0], data.position[1]),
id,
selectedIds,
);
this.machines.set(id, machineRepr);
scene.add(machineRepr.group);
});
renderLoop.requestRender();
});
// Push positions of previously existing machines to the scene
// TODO: Maybe we should do this in some post query hook?
//
// Effect 1: sync query → store (positions)
//
createEffect(() => {
if (!machinesQueryResult.data) return;
const actualMachines = Object.keys(machinesQueryResult.data);
const actualIds = Object.keys(machinesQueryResult.data);
const machinePositions = machinePositionsSignal();
const placed: Set<string> = machinePositions
? new Set(Object.keys(machinePositions))
: new Set();
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
// Push not explizitly placed machines to the scene
// TODO: Make the user place them manually
// We just calculate some next free position
for (const id of nonPlaced) {
console.log("adding", id);
const position = this.nextGridPos();
setMachinePos(id, position);
// Remove stale
for (const id of Object.keys(machinePositions)) {
if (!actualIds.includes(id)) {
setMachinePos(id, null);
}
}
// Add missing
for (const id of actualIds) {
if (!machinePositions[id]) {
const pos = this.nextGridPos();
setMachinePos(id, pos);
}
}
});
//
// Effect 2: sync store → scene
//
createEffect(() => {
const positions = machinePositionsSignal();
// Remove machines from scene
for (const [id, repr] of this.machines) {
if (!(id in positions)) {
repr.dispose(scene);
this.machines.delete(id);
}
}
// Add or update machines
for (const [id, data] of Object.entries(positions)) {
let repr = this.machines.get(id);
if (!repr) {
repr = new MachineRepr(
scene,
registry,
new THREE.Vector2(data.position[0], data.position[1]),
id,
selectedIds,
highlightGroups,
);
this.machines.set(id, repr);
scene.add(repr.group);
} else {
repr.setPosition(
new THREE.Vector2(data.position[0], data.position[1]),
);
}
}
renderLoop.requestRender();
});
return disposeEffects;

View File

@@ -13,6 +13,7 @@ const CUBE_COLOR = 0xe2eff0;
const CUBE_EMISSIVE = 0x303030;
const CUBE_SELECTED_COLOR = 0x4b6767;
const HIGHLIGHT_COLOR = 0x00ee66;
const BASE_COLOR = 0xdbeaeb;
const BASE_EMISSIVE = 0x0c0c0c;
@@ -36,6 +37,7 @@ export class MachineRepr {
position: THREE.Vector2,
id: string,
selectedSignal: Accessor<Set<string>>,
highlightGroups: Record<string, Set<string>>, // Reactive store
) {
this.id = id;
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
@@ -89,23 +91,38 @@ export class MachineRepr {
this.disposeRoot = createRoot((disposeEffects) => {
createEffect(
on(selectedSignal, (selectedIds) => {
const isSelected = selectedIds.has(this.id);
// Update cube
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
);
on(
[selectedSignal, () => Object.entries(highlightGroups)],
([selectedIds, groups]) => {
const isSelected = selectedIds.has(this.id);
const highlightedGroups = groups
.filter(([, ids]) => ids.has(this.id))
.map(([name]) => name);
// Update base
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
);
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
);
// console.log("MachineRepr effect", id, highlightedGroups);
// Update cube
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
);
renderLoop.requestRender();
}),
// Update base
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
);
// TOOD: Find a different way to show both selected & highlighted
// I.e. via outline or pulsing
// selected > highlighted > normal
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
);
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
// );
renderLoop.requestRender();
},
),
);
return disposeEffects;
@@ -121,6 +138,11 @@ export class MachineRepr {
});
}
public setPosition(position: THREE.Vector2) {
this.group.position.set(position.x, 0, position.y);
renderLoop.requestRender();
}
private createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
@@ -154,6 +176,14 @@ export class MachineRepr {
this.geometry.dispose();
this.material.dispose();
for (const child of this.cubeMesh.children) {
if (child instanceof THREE.Mesh)
(child.material as THREE.Material).dispose();
if (child instanceof CSS2DObject) child.element.remove();
if (child instanceof THREE.Object3D) child.remove();
}
(this.baseMesh.material as THREE.Material).dispose();
}
}

View File

@@ -4,6 +4,8 @@
cursor: pointer;
}
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
<Show when={show()}> */
.toolbar-container {
@apply absolute bottom-10 z-10 w-full;
@apply flex justify-center items-center;

View File

@@ -1,4 +1,11 @@
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
import {
createSignal,
createEffect,
onCleanup,
onMount,
on,
JSX,
} from "solid-js";
import "./cubes.css";
import * as THREE from "three";
@@ -8,7 +15,7 @@ import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { MachinesQueryResult } from "../hooks/queries";
import { MachinesQueryResult, useMachinesQuery } from "../hooks/queries";
import { SceneData } from "../stores/clan";
import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop";
@@ -31,14 +38,45 @@ function garbageCollectGroup(group: THREE.Group) {
group.clear(); // Clear the group
}
// Can be imported by others via wrappers below
// Global signal for last clicked machine
const [lastClickedMachine, setLastClickedMachine] = createSignal<string | null>(
null,
);
// Exported so others could also emit the signal if needed
// And for testing purposes
export function emitMachineClick(id: string | null) {
setLastClickedMachine(id);
if (id) {
// Clear after a short delay to allow re-clicking the same machine
setTimeout(() => {
setLastClickedMachine(null);
}, 100);
}
}
/** Hook for components to subscribe */
export function useMachineClick() {
return lastClickedMachine;
}
/*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create"
>("default");
export { worldMode, setWorldMode };
export function CubeScene(props: {
cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>;
selectedIds: Accessor<Set<string>>;
onSelect: (v: Set<string>) => void;
sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number]) => void;
setMachinePos: (machineId: string, pos: [number, number] | null) => void;
isLoading: boolean;
clanURI: string;
toolbarPopup?: JSX.Element;
}) {
let container: HTMLDivElement;
let scene: THREE.Scene;
@@ -64,8 +102,6 @@ export function CubeScene(props: {
"grid",
);
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [cameraInfo, setCameraInfo] = createSignal({
@@ -74,7 +110,7 @@ export function CubeScene(props: {
});
// Grid configuration
const GRID_SIZE = 2;
const GRID_SIZE = 1;
const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; //
@@ -212,7 +248,8 @@ export function CubeScene(props: {
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
controls.mouseButtons.RIGHT = null;
controls.enableRotate = false;
// controls.rotateSpeed = -0.8;
// controls.enableRotate = false;
controls.minZoom = 1.2;
controls.maxZoom = 3.5;
controls.addEventListener("change", () => {
@@ -360,7 +397,7 @@ export function CubeScene(props: {
);
// Click handler:
// - Select/deselects a cube in "view" mode
// - Select/deselects a cube in mode
// - Creates a new cube in "create" mode
const onClick = (event: MouseEvent) => {
if (worldMode() === "create") {
@@ -381,7 +418,7 @@ export function CubeScene(props: {
.finally(() => {
if (initBase) initBase.visible = false;
setWorldMode("view");
setWorldMode("default");
});
}
@@ -399,8 +436,13 @@ export function CubeScene(props: {
if (intersects.length > 0) {
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id;
toggleSelection(id);
if (worldMode() === "select") toggleSelection(id);
emitMachineClick(id); // notify subscribers
} else {
emitMachineClick(null);
props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
}
};
@@ -505,6 +547,7 @@ export function CubeScene(props: {
const intersects = raycaster.intersectObject(floor);
if (intersects.length > 0) {
const point = intersects[0].point;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
@@ -512,6 +555,17 @@ export function CubeScene(props: {
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
// Skip snapping if there's already a cube at this position
if (props.sceneStore()) {
const positions = Object.values(props.sceneStore());
const intersects = positions.some(
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
if (intersects) {
return;
}
}
if (
Math.abs(initBase.position.x - snapped.x) > 0.01 ||
Math.abs(initBase.position.z - snapped.z) > 0.01
@@ -524,23 +578,29 @@ export function CubeScene(props: {
}
};
const machinesQuery = useMachinesQuery(props.clanURI);
return (
<>
<div class="cubes-scene-container" ref={(el) => (container = el)} />
<div class="toolbar-container">
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
{props.toolbarPopup}
</div>
<Toolbar>
<ToolbarButton
description="Select machine"
name="Select"
icon="Cursor"
onClick={() => setWorldMode("view")}
selected={worldMode() === "view"}
onClick={() =>
setWorldMode((v) => (v === "select" ? "default" : "select"))
}
selected={worldMode() === "select"}
/>
<ToolbarButton
description="Create new machine"
name="new-machine"
icon="NewMachine"
disabled={positionMode() === "circle"}
onClick={onAddClick}
selected={worldMode() === "create"}
/>
@@ -548,24 +608,18 @@ export function CubeScene(props: {
<ToolbarButton
description="Add new Service"
name="modules"
icon="Modules"
icon="Services"
selected={worldMode() === "service"}
onClick={() => {
if (positionMode() === "grid") {
setPositionMode("circle");
setWorldMode("view");
grid.visible = false;
} else {
setPositionMode("grid");
grid.visible = true;
}
renderLoop.requestRender();
setWorldMode((v) => (v === "service" ? "default" : "service"));
}}
/>
{/* <ToolbarButton
description="Delete Machine"
name="delete"
icon="Trash"
/> */}
<ToolbarButton
icon="Reload"
name="Reload"
description="Reload machines"
onClick={() => machinesQuery.refetch()}
/>
</Toolbar>
</div>
</>

View File

@@ -0,0 +1,42 @@
// highlightStore.ts
import { createStore, produce } from "solid-js/store";
// groups: { [groupName: string]: Set<nodeId> }
const [highlightGroups, setHighlightGroups] = createStore<
Record<string, Set<string>>
>({});
// Add highlight
export function highlight(group: string, nodeId: string) {
setHighlightGroups(group, (prev = new Set()) => {
const next = new Set(prev);
next.add(nodeId);
return next;
});
}
// Remove highlight
export function unhighlight(group: string, nodeId: string) {
setHighlightGroups(group, (prev = new Set()) => {
const next = new Set(prev);
next.delete(nodeId);
return next;
});
}
// Clear group
export function clearHighlight(group: string) {
setHighlightGroups(group, () => new Set());
}
export function clearAllHighlights() {
setHighlightGroups(
produce((s) => {
for (const key of Object.keys(s)) {
Reflect.deleteProperty(s, key);
}
}),
);
}
export { highlightGroups, setHighlightGroups };

View File

@@ -52,6 +52,7 @@ export interface InstallStoreType {
};
install: {
targetHost: string;
port?: string;
machineName: string;
mainDisk: string;
// ...TODO Vars
@@ -96,6 +97,8 @@ export const InstallModal = (props: InstallModalProps) => {
switch (currentStep.id) {
case "create:progress":
case "create:done":
case "install:progress":
case "install:done":
return currentStep.class;
default:

View File

@@ -13,7 +13,7 @@ const ChoiceLocalOrRemote = () => {
onClick={() => stepSignal.setActiveStep("local:choice")}
/>
<NavSection
label="The Machine is remote and i have ssh access to it"
label="The machine is remote and I have ssh access to it"
onClick={() => stepSignal.setActiveStep("install:address")}
/>
</div>

View File

@@ -24,8 +24,8 @@ import cx from "classnames";
const Prose = () => (
<StepLayout
body={
<>
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md px-4 py-6 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
<div class="flex flex-col gap-4">
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md p-4 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
<div class="flex flex-col gap-3">
<Typography
hierarchy="label"
@@ -74,7 +74,7 @@ const Prose = () => (
</Typography>
</div>
</div>
</>
</div>
}
footer={<StepFooter nextText="start" />}
/>

View File

@@ -44,6 +44,12 @@ const ConfigureAdressSchema = v.object({
v.string("Please set a target host."),
v.nonEmpty("Please set a target host."),
),
port: v.optional(
v.pipe(
v.string(),
v.transform((val) => (val === "" ? undefined : val)),
),
),
});
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
@@ -56,6 +62,7 @@ const ConfigureAddress = () => {
validate: valiForm(ConfigureAdressSchema),
initialValues: {
targetHost: store.install?.targetHost,
port: store.install?.port,
},
});
@@ -69,7 +76,11 @@ const ConfigureAddress = () => {
event,
) => {
console.log("targetHost set", values);
set("install", (s) => ({ ...s, targetHost: values.targetHost }));
set("install", (s) => ({
...s,
targetHost: values.targetHost,
port: values.port,
}));
// Here you would typically trigger the ISO creation process
stepSignal.next();
@@ -81,10 +92,18 @@ const ConfigureAddress = () => {
return;
}
const portValue = getValue(formStore, "port");
const port = portValue ? parseInt(portValue, 10) : undefined;
setLoading(true);
const call = client.fetch("check_machine_ssh_login", {
remote: {
address,
...(port && { port }),
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
},
});
const result = await call.result;
@@ -121,6 +140,25 @@ const ConfigureAddress = () => {
/>
)}
</Field>
<Field name="port">
{(field, props) => (
<TextInput
{...field}
label="SSH Port"
description="SSH port (default: 22)"
value={field.value}
orientation="horizontal"
validationState={
getError(formStore, "port") ? "invalid" : "valid"
}
input={{
...props,
placeholder: "22",
type: "number",
}}
/>
)}
</Field>
</Fieldset>
</div>
}
@@ -166,23 +204,41 @@ const CheckHardware = () => {
const client = useApiClient();
const [updatingHardwareReport, setUpdatingHardwareReport] =
createSignal(false);
const handleUpdateSummary = async () => {
// TODO: Debounce
const call = client.fetch("run_machine_hardware_info", {
target_host: {
address: store.install.targetHost,
},
opts: {
machine: {
flake: {
identifier: clanUri,
setUpdatingHardwareReport(true);
const port = store.install.port
? parseInt(store.install.port, 10)
: undefined;
try {
// TODO: Debounce
const call = client.fetch("run_machine_hardware_info", {
target_host: {
address: store.install.targetHost,
...(port && { port }),
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
name: store.install.machineName,
},
},
});
await call.result;
hardwareQuery.refetch();
opts: {
machine: {
flake: {
identifier: clanUri,
},
name: store.install.machineName,
},
},
});
await call.result;
await hardwareQuery.refetch();
} finally {
setUpdatingHardwareReport(false);
}
};
const reportExists = () => hardwareQuery?.data?.hardware_config !== "none";
@@ -197,12 +253,12 @@ const CheckHardware = () => {
Hardware Report
</Typography>
<Button
disabled={hardwareQuery.isLoading}
disabled={hardwareQuery.isLoading || updatingHardwareReport()}
hierarchy="secondary"
startIcon="Report"
onClick={handleUpdateSummary}
class="flex gap-3"
loading={hardwareQuery.isFetching}
loading={hardwareQuery.isFetching || updatingHardwareReport()}
>
Update hardware report
</Button>
@@ -574,6 +630,10 @@ const InstallSummary = () => {
}));
await runGenerators.result; // Wait for the generators to run
const port = store.install.port
? parseInt(store.install.port, 10)
: undefined;
const runInstall = client.fetch("run_machine_install", {
opts: {
machine: {
@@ -585,6 +645,11 @@ const InstallSummary = () => {
},
target_host: {
address: store.install.targetHost,
...(port && { port }),
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
},
});
set("install", (s) => ({
@@ -609,6 +674,14 @@ const InstallSummary = () => {
<Orienter orientation="horizontal">
<Display label="Address" value={store.install.targetHost} />
</Orienter>
{store.install.port && (
<>
<Divider orientation="horizontal" />
<Orienter orientation="horizontal">
<Display label="SSH Port" value={store.install.port} />
</Orienter>
</>
)}
</Fieldset>
<Fieldset legend="Disk">
<Orienter orientation="horizontal">
@@ -658,13 +731,13 @@ const InstallProgress = () => {
);
return (
<div class="relative flex size-full flex-col items-center justify-center bg-inv-4">
<div class="relative flex size-full flex-col items-center justify-end bg-inv-4">
<img
src="/logos/usb-stick-min.png"
alt="usb logo"
class="absolute top-2 z-0"
/>
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 fg-inv-1">
<Typography
hierarchy="title"
size="default"
@@ -796,10 +869,12 @@ export const installSteps = [
id: "install:progress",
content: InstallProgress,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
{
id: "install:done",
content: InstallDone,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
] as const;

View File

@@ -0,0 +1,35 @@
.content {
@apply px-3 flex flex-col gap-5 py-6;
border: 1px solid #2e4a4b;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.header {
@apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #2e4a4b;
}
.footer {
@apply py-3 px-4 flex justify-end w-full;
border-radius: 0 0 8px 8px;
border-top: 1px solid #2e4a4b;
}
.backgroundAlt {
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
}

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { ServiceWorkflow } from "./Service";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
list_service_modules: [
{
module: { name: "Borgbackup", input: "clan-core" },
info: {
manifest: {
name: "Borgbackup",
description: "This is module A",
},
roles: {
client: null,
server: null,
},
},
},
{
module: { name: "Zerotier", input: "clan-core" },
info: {
manifest: {
name: "Zerotier",
description: "This is module B",
},
roles: {
peer: null,
moon: null,
controller: null,
},
},
},
{
module: { name: "Admin", input: "clan-core" },
info: {
manifest: {
name: "Admin",
description: "This is module B",
},
roles: {
default: null,
},
},
},
{
module: { name: "Garage", input: "lo-l" },
info: {
manifest: {
name: "Garage",
description: "This is module B",
},
roles: {
default: null,
},
},
},
],
list_machines: {
jon: {
name: "jon",
tags: ["all", "nixos", "tag1"],
},
sara: {
name: "sara",
tags: ["all", "darwin", "tag2"],
},
kyra: {
name: "kyra",
tags: ["all", "darwin", "tag2"],
},
leila: {
name: "leila",
tags: ["all", "darwin", "tag2"],
},
},
list_tags: {
options: ["desktop", "server", "full", "only", "streaming", "backup"],
special: ["all", "nixos", "darwin"],
},
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof ServiceWorkflow> = {
title: "workflows/service",
component: ServiceWorkflow,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div>
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof ServiceWorkflow>;
export const Default: Story = {
args: {},
};
export const SelectRoleMembers: Story = {
render: () => (
<ServiceWorkflow
handleSubmit={(instance) => {
console.log("Submitted instance:", instance);
}}
initialStep="select:members"
initialStore={{
currentRole: "peer",
}}
/>
),
};

View File

@@ -0,0 +1,614 @@
import {
createStepper,
getStepStore,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import { useClanURI } from "@/src/hooks/clan";
import {
MachinesQuery,
ServiceModules,
TagsQuery,
useMachinesQuery,
useServiceInstances,
useServiceModules,
useTags,
} from "@/src/hooks/queries";
import {
createEffect,
createMemo,
createSignal,
For,
JSX,
Show,
on,
onMount,
} from "solid-js";
import { Search } from "@/src/components/Search/Search";
import Icon from "@/src/components/Icon/Icon";
import { Combobox } from "@kobalte/core/combobox";
import { Typography } from "@/src/components/Typography/Typography";
import { TagSelect } from "@/src/components/Search/TagSelect";
import { Tag } from "@/src/components/Tag/Tag";
import { createForm, FieldValues } from "@modular-forms/solid";
import styles from "./Service.module.css";
import { TextInput } from "@/src/components/Form/TextInput";
import { Button } from "@/src/components/Button/Button";
import cx from "classnames";
import { BackButton } from "../Steps";
import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
import { useMachineClick } from "@/src/scene/cubes";
import {
clearAllHighlights,
highlightGroups,
setHighlightGroups,
} from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside";
type ModuleItem = ServiceModules[number];
interface Module {
value: string;
input?: string;
label: string;
description: string;
raw: ModuleItem;
instances: string[];
}
const SelectService = () => {
const clanURI = useClanURI();
const stepper = useStepper<ServiceSteps>();
const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const machinesQuery = useMachinesQuery(clanURI);
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions(
serviceModulesQuery.data.map((m) => ({
value: `${m.module.name}:${m.module.input}`,
label: m.module.name,
description: m.info.manifest.description,
input: m.module.input,
raw: m,
// TODO: include the instances that use this module
instances: Object.entries(serviceInstancesQuery.data)
.filter(
([name, i]) =>
i.module?.name === m.module.name &&
(!i.module?.input || i.module?.input === m.module.input),
)
.map(([name, _]) => name),
})),
);
}
});
const [store, set] = getStepStore<ServiceStoreType>(stepper);
return (
<Search<Module>
loading={serviceModulesQuery.isLoading}
height="13rem"
onChange={(module) => {
if (!module) return;
set("module", {
name: module.raw.module.name,
input: module.raw.module.input,
raw: module.raw,
});
// TODO: Ideally we need to ask
// - create new
// - update existing (and select which one)
// For now:
// Create a new instance, if there are no instances yet
// Update the first instance, if there is one
if (module.instances.length === 0) {
set("action", "create");
} else {
if (!serviceInstancesQuery.data) return;
if (!machinesQuery.data) return;
set("action", "update");
const instanceName = module.instances[0];
const instance = serviceInstancesQuery.data[instanceName];
console.log("Editing existing instance", module);
for (const role of Object.keys(instance.roles || {})) {
const tags = Object.keys(instance.roles?.[role].tags || {});
const machines = Object.keys(instance.roles?.[role].machines || {});
const machineTags = machines.map((m) => ({
value: "m_" + m,
label: m,
type: "machine" as const,
}));
const tagsTags = tags.map((t) => {
return {
value: "t_" + t,
label: t,
type: "tag" as const,
members: Object.entries(machinesQuery.data || {})
.filter(([_, m]) => m.tags?.includes(t))
.map(([k]) => k),
};
});
console.log("Members for role", role, [
...machineTags,
...tagsTags,
]);
if (!store.roles) {
set("roles", {});
}
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
a.label.localeCompare(b.label),
);
set("roles", role, roleMembers);
console.log("set", store.roles);
}
// Initialize the roles with the existing members
}
stepper.next();
}}
options={moduleOptions()}
renderItem={(item, opts) => {
return (
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex gap-1.5">
<Show when={item.instances.length > 0}>
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
<Typography hierarchy="label" weight="bold" size="xxs">
Added
</Typography>
</div>
</Show>
<Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span class="inline-block max-w-32 truncate align-middle">
{item.description}
</span>
<span class="inline-block max-w-8 truncate align-middle">
by {item.input}
</span>
</Typography>
</div>
</div>
);
}}
/>
);
};
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
createMemo<TagType[]>(() => {
const tags = tagsQuery.data;
const machines = machinesQuery.data;
if (!tags || !machines) {
return [];
}
const machineOptions = Object.keys(machines).map((m) => ({
label: m,
value: "m_" + m,
type: "machine" as const,
}));
const tagOptions = [...tags.options, ...tags.special].map((tag) => ({
type: "tag" as const,
label: tag,
value: "t_" + tag,
members: Object.entries(machines)
.filter(([_, v]) => v.tags?.includes(tag))
.map(([k]) => k),
}));
return [...machineOptions, ...tagOptions].sort((a, b) =>
a.label.localeCompare(b.label),
);
});
interface RolesForm extends FieldValues {
roles: Record<string, string[]>;
instanceName: string;
}
const ConfigureService = () => {
const stepper = useStepper<ServiceSteps>();
const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RolesForm>({
initialValues: {
// Default to the module name, until we support multiple instances
instanceName: store.module.name,
},
});
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = (values: RolesForm) => {
const roles: Record<string, RoleType> = Object.fromEntries(
Object.entries(store.roles).map(([key, value]) => [
key,
{
machines: Object.fromEntries(
value.filter((v) => v.type === "machine").map((v) => [v.label, {}]),
),
tags: Object.fromEntries(
value.filter((v) => v.type === "tag").map((v) => [v.label, {}]),
),
},
]),
);
store.handleSubmit(
{
name: values.instanceName,
module: {
name: store.module.name,
input: store.module.input,
},
roles,
},
store.action,
);
};
return (
<Form onSubmit={handleSubmit}>
<div class={cx(styles.header, styles.backgroundAlt)}>
<div class="overflow-hidden rounded-sm">
<Icon icon="Services" size={36} inverted />
</div>
<div class="flex flex-col">
<Typography hierarchy="body" size="s" weight="medium" inverted>
{store.module.name}
</Typography>
<Field name="instanceName">
{(field, input) => (
<TextInput
{...field}
value={field.value}
size="s"
inverted
required
readOnly={true}
orientation="horizontal"
input={input}
/>
)}
</Field>
</div>
<Button
icon="Close"
color="primary"
ghost
size="s"
class="ml-auto"
onClick={store.close}
/>
</div>
<div class={styles.content}>
<For each={Object.keys(store.module.raw?.info.roles || {})}>
{(role) => {
const values = store.roles?.[role] || [];
return (
<TagSelect<TagType>
label={role}
renderItem={(item: TagType) => (
<Tag
inverted
icon={(tag) => (
<Icon
icon={item.type === "machine" ? "Machine" : "Tag"}
size="0.5rem"
inverted={tag.inverted}
/>
)}
>
{item.label}
</Tag>
)}
values={values}
options={options()}
onClick={() => {
set("currentRole", role);
stepper.next();
}}
/>
);
}}
</For>
</div>
<div class={cx(styles.footer, styles.backgroundAlt)}>
<BackButton ghost hierarchy="primary" class="mr-auto" />
<Button hierarchy="secondary" type="submit">
<Show when={store.action === "create"}>Add Service</Show>
<Show when={store.action === "update"}>Save Changes</Show>
</Button>
</div>
</Form>
);
};
type TagType =
| {
value: string;
label: string;
type: "machine";
}
| {
value: string;
label: string;
type: "tag";
members: string[];
};
const ConfigureRole = () => {
const stepper = useStepper<ServiceSteps>();
const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [members, setMembers] = createSignal<TagType[]>(
store.roles?.[store.currentRole || ""] || [],
);
const lastClickedMachine = useMachineClick();
createEffect(() => {
console.log("Current role", store.currentRole, members());
clearAllHighlights();
setHighlightGroups({
[store.currentRole as string]: new Set(
members().flatMap((m) => {
if (m.type === "machine") return m.label;
return m.members;
}),
),
});
console.log("now", highlightGroups);
});
onMount(() => setHighlightGroups(() => ({})));
createEffect(
on(lastClickedMachine, (machine) => {
// const machine = lastClickedMachine();
const currentMembers = members();
console.log("Clicked machine", machine, currentMembers);
if (!machine) return;
const machineTagName = "m_" + machine;
const existing = currentMembers.find((m) => m.value === machineTagName);
if (existing) {
// Remove
setMembers(currentMembers.filter((m) => m.value !== machineTagName));
} else {
// Add
setMembers([
...currentMembers,
{ value: machineTagName, label: machine, type: "machine" },
]);
}
}),
);
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = () => {
if (!store.currentRole) return;
if (!store.roles) {
set("roles", {});
}
set("roles", (r) => ({ ...r, [store.currentRole as string]: members() }));
stepper.setActiveStep("view:members");
};
return (
<form onSubmit={() => handleSubmit()}>
<div class={cx(styles.backgroundAlt, "rounded-md")}>
<div class="flex w-full flex-col ">
<SearchMultiple<TagType>
values={members()}
options={options()}
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
headerChildren={
<div class="flex w-full gap-2.5">
<BackButton
ghost
size="xs"
hierarchy="primary"
// onClick={() => clearAllHighlights()}
/>
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
class="capitalize"
>
Select {store.currentRole}
</Typography>
</div>
}
placeholder={"Search for Machine or Tags"}
renderItem={(item, opts) => (
<div class={cx("flex w-full items-center gap-2 px-3 py-2")}>
<Combobox.ItemIndicator>
<Show when={opts.selected} fallback={<Icon icon="Code" />}>
<Icon icon="Checkmark" color="primary" inverted />
</Show>
</Combobox.ItemIndicator>
<Combobox.ItemLabel class="flex items-center gap-2">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.label}
</Typography>
<Show when={item.type === "tag" && item}>
{(tag) => (
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted
color="secondary"
tag="div"
>
{tag().members.length}
</Typography>
)}
</Show>
</Combobox.ItemLabel>
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
/>
</div>
)}
height="20rem"
virtualizerOptions={{
estimateSize: () => 38,
}}
onChange={(selection) => {
setMembers(selection);
}}
/>
</div>
<div class={cx(styles.footer, styles.backgroundAlt)}>
<Button hierarchy="secondary" type="submit">
Confirm
</Button>
</div>
</div>
</form>
);
};
const steps = [
{
id: "select:service",
content: SelectService,
},
{
id: "view:members",
content: ConfigureService,
},
{
id: "select:members",
content: ConfigureRole,
},
{ id: "settings", content: () => <div>Adjust settings here.</div> },
] as const;
export type ServiceSteps = typeof steps;
// TODO: Ideally we would impot this from a backend model package
export interface InventoryInstance {
name: string;
module: {
name: string;
input?: string;
};
roles: Record<string, RoleType>;
}
interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export interface ServiceStoreType {
module: {
name: string;
input: string;
raw?: ModuleItem;
};
roles: Record<string, TagType[]>;
currentRole?: string;
close: () => void;
handleSubmit: SubmitServiceHandler;
action: "create" | "update";
}
export type SubmitServiceHandler = (
values: InventoryInstance,
action: "create" | "update",
) => void | Promise<void>;
interface ServiceWorkflowProps {
initialStep?: ServiceSteps[number]["id"];
initialStore?: Partial<ServiceStoreType>;
onClose?: () => void;
handleSubmit: SubmitServiceHandler;
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
}
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
const stepper = createStepper(
{ steps },
{
initialStep: props.initialStep || "select:service",
initialStoreData: {
...props.initialStore,
close: () => props.onClose?.(),
handleSubmit: props.handleSubmit,
} satisfies Partial<ServiceStoreType>,
},
);
createEffect(() => {
if (stepper.currentStep().id !== "select:members") {
clearAllHighlights();
}
});
let ref: HTMLDivElement;
useClickOutside(
() => ref,
() => {
if (stepper.currentStep().id === "select:service") props.onClose?.();
},
);
return (
<div
ref={(e) => (ref = e)}
id="add-service"
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
{...props.rootProps}
>
<StepperProvider stepper={stepper}>
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
</StepperProvider>
</div>
);
};

View File

@@ -0,0 +1,7 @@
.step {
@apply relative flex size-full flex-col justify-between gap-8;
.footer {
@apply flex justify-between;
}
}

View File

@@ -2,6 +2,7 @@ import { JSX } from "solid-js";
import { useStepper } from "../hooks/stepper";
import { Button, ButtonProps } from "../components/Button/Button";
import { InstallSteps } from "./Install/install";
import styles from "./Steps.module.css";
interface StepLayoutProps {
body: JSX.Element;
@@ -9,7 +10,7 @@ interface StepLayoutProps {
}
export const StepLayout = (props: StepLayoutProps) => {
return (
<div class="flex size-full grow flex-col justify-between gap-6">
<div class={styles.step}>
{props.body}
{props.footer}
</div>
@@ -34,7 +35,8 @@ export const NextButton = (props: NextButtonProps) => {
);
};
export const BackButton = () => {
type BackButtonProps = ButtonProps & {};
export const BackButton = (props: BackButtonProps) => {
const stepSignal = useStepper<InstallSteps>();
return (
<Button
@@ -44,6 +46,7 @@ export const BackButton = () => {
onClick={() => {
stepSignal.previous();
}}
{...props}
></Button>
);
};
@@ -63,7 +66,7 @@ interface StepFooterProps {
export const StepFooter = (props: StepFooterProps) => {
const stepper = useStepper<InstallSteps>();
return (
<div class="flex justify-between py-4">
<div class={styles.footer}>
<BackButton />
<NextButton type="button" onClick={() => stepper.next()}>
{props.nextText || undefined}

View File

@@ -8,7 +8,8 @@ from clan_lib.api import load_in_all_api_functions
def main() -> None:
load_in_all_api_functions()
from clan_lib.api import API
# import lazily since we otherwise we do not have all api functions loaded according to Qubasa
from clan_lib.api import API # noqa: PLC0415
schema = API.to_json_schema()
print(f"""{json.dumps(schema, indent=2)}""")

View File

@@ -539,11 +539,11 @@ def main() -> None:
try:
args.func(args)
except ClanError as e:
except ClanError:
if debug:
log.exception("Exited with error")
else:
log.error("%s", e)
log.exception("Exited with error")
sys.exit(1)
except KeyboardInterrupt as ex:
log.warning("Interrupted by user", exc_info=ex)

View File

@@ -4,13 +4,20 @@ import json
import subprocess
import threading
from collections.abc import Callable, Iterable
from pathlib import Path
from types import ModuleType
from typing import Any
from clan_lib.cmd import run
from clan_lib.dirs import get_clan_flake_toplevel_or_env
from clan_lib.flake.flake import Flake
from clan_lib.nix import nix_eval
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.templates import list_templates
from .secrets.groups import list_groups
from .secrets.secrets import list_secrets
from .secrets.users import list_users
"""
This module provides dynamic completions.
@@ -31,7 +38,6 @@ COMPLETION_TIMEOUT: int = 3
def clan_dir(flake: str | None) -> str | None:
if flake is not None:
return flake
from clan_lib.dirs import get_clan_flake_toplevel_or_env
path_result = get_clan_flake_toplevel_or_env()
return str(path_result) if path_result is not None else None
@@ -67,8 +73,7 @@ def complete_machines(
if thread.is_alive():
return iter([])
machines_dict = dict.fromkeys(machines, "machine")
return machines_dict
return dict.fromkeys(machines, "machine")
def complete_services_for_machine(
@@ -112,8 +117,7 @@ def complete_services_for_machine(
if thread.is_alive():
return iter([])
services_dict = dict.fromkeys(services, "service")
return services_dict
return dict.fromkeys(services, "service")
def complete_backup_providers_for_machine(
@@ -156,8 +160,7 @@ def complete_backup_providers_for_machine(
if thread.is_alive():
return iter([])
providers_dict = dict.fromkeys(providers, "provider")
return providers_dict
return dict.fromkeys(providers, "provider")
def complete_state_services_for_machine(
@@ -200,8 +203,7 @@ def complete_state_services_for_machine(
if thread.is_alive():
return iter([])
providers_dict = dict.fromkeys(providers, "service")
return providers_dict
return dict.fromkeys(providers, "service")
def complete_secrets(
@@ -210,10 +212,6 @@ def complete_secrets(
**_kwargs: Any,
) -> Iterable[str]:
"""Provides completion functionality for clan secrets"""
from clan_lib.flake.flake import Flake
from .secrets.secrets import list_secrets
flake = (
clan_dir_result
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
@@ -223,8 +221,7 @@ def complete_secrets(
secrets = list_secrets(Flake(flake).path)
secrets_dict = dict.fromkeys(secrets, "secret")
return secrets_dict
return dict.fromkeys(secrets, "secret")
def complete_users(
@@ -233,10 +230,6 @@ def complete_users(
**_kwargs: Any,
) -> Iterable[str]:
"""Provides completion functionality for clan users"""
from pathlib import Path
from .secrets.users import list_users
flake = (
clan_dir_result
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
@@ -246,8 +239,7 @@ def complete_users(
users = list_users(Path(flake))
users_dict = dict.fromkeys(users, "user")
return users_dict
return dict.fromkeys(users, "user")
def complete_groups(
@@ -256,10 +248,6 @@ def complete_groups(
**_kwargs: Any,
) -> Iterable[str]:
"""Provides completion functionality for clan groups"""
from pathlib import Path
from .secrets.groups import list_groups
flake = (
clan_dir_result
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
@@ -270,8 +258,7 @@ def complete_groups(
groups_list = list_groups(Path(flake))
groups = [group.name for group in groups_list]
groups_dict = dict.fromkeys(groups, "group")
return groups_dict
return dict.fromkeys(groups, "group")
def complete_templates_disko(
@@ -280,8 +267,6 @@ def complete_templates_disko(
**_kwargs: Any,
) -> Iterable[str]:
"""Provides completion functionality for disko templates"""
from clan_lib.templates import list_templates
flake = (
clan_dir_result
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
@@ -293,8 +278,7 @@ def complete_templates_disko(
disko_template_list = list_all_templates.builtins.get("disko")
if disko_template_list:
disko_templates = list(disko_template_list)
disko_dict = dict.fromkeys(disko_templates, "disko")
return disko_dict
return dict.fromkeys(disko_templates, "disko")
return []
@@ -304,8 +288,6 @@ def complete_templates_clan(
**_kwargs: Any,
) -> Iterable[str]:
"""Provides completion functionality for clan templates"""
from clan_lib.templates import list_templates
flake = (
clan_dir_result
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
@@ -317,8 +299,7 @@ def complete_templates_clan(
clan_template_list = list_all_templates.builtins.get("clan")
if clan_template_list:
clan_templates = list(clan_template_list)
clan_dict = dict.fromkeys(clan_templates, "clan")
return clan_dict
return dict.fromkeys(clan_templates, "clan")
return []
@@ -331,8 +312,6 @@ def complete_vars_for_machine(
Only completes vars that already exist in the vars directory on disk.
This is fast as it only scans the filesystem without any evaluation.
"""
from pathlib import Path
machine_name = getattr(parsed_args, "machine", None)
if not machine_name:
return []
@@ -362,8 +341,7 @@ def complete_vars_for_machine(
except (OSError, PermissionError):
pass
vars_dict = dict.fromkeys(vars_list, "var")
return vars_dict
return dict.fromkeys(vars_list, "var")
def complete_target_host(
@@ -404,8 +382,7 @@ def complete_target_host(
if thread.is_alive():
return iter([])
providers_dict = dict.fromkeys(target_hosts, "target_host")
return providers_dict
return dict.fromkeys(target_hosts, "target_host")
def complete_tags(
@@ -474,8 +451,7 @@ def complete_tags(
if any(thread.is_alive() for thread in threads):
return iter([])
providers_dict = dict.fromkeys(tags, "tag")
return providers_dict
return dict.fromkeys(tags, "tag")
def add_dynamic_completer(

View File

@@ -13,24 +13,24 @@ def check_secrets(machine: Machine, service: None | str = None) -> bool:
missing_secret_facts = []
missing_public_facts = []
services = [service] if service else list(machine.facts_data.keys())
for service in services:
for secret_fact in machine.facts_data[service]["secret"]:
for svc in services:
for secret_fact in machine.facts_data[svc]["secret"]:
if isinstance(secret_fact, str):
secret_name = secret_fact
else:
secret_name = secret_fact["name"]
if not machine.secret_facts_store.exists(service, secret_name):
if not machine.secret_facts_store.exists(svc, secret_name):
machine.info(
f"Secret fact '{secret_fact}' for service '{service}' is missing.",
f"Secret fact '{secret_fact}' for service '{svc}' is missing.",
)
missing_secret_facts.append((service, secret_name))
missing_secret_facts.append((svc, secret_name))
for public_fact in machine.facts_data[service]["public"]:
if not machine.public_facts_store.exists(service, public_fact):
for public_fact in machine.facts_data[svc]["public"]:
if not machine.public_facts_store.exists(svc, public_fact):
machine.info(
f"Public fact '{public_fact}' for service '{service}' is missing.",
f"Public fact '{public_fact}' for service '{svc}' is missing.",
)
missing_public_facts.append((service, public_fact))
missing_public_facts.append((svc, public_fact))
machine.debug(f"missing_secret_facts: {missing_secret_facts}")
machine.debug(f"missing_public_facts: {missing_public_facts}")

View File

@@ -7,6 +7,7 @@ from collections.abc import Callable
from pathlib import Path
from tempfile import TemporaryDirectory
from clan_lib import bwrap
from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
@@ -104,7 +105,6 @@ def generate_service_facts(
machine.facts_data[service]["generator"]["prompt"],
)
env["prompt_value"] = prompt_value
from clan_lib import bwrap
if sys.platform == "linux" and bwrap.bubblewrap_works():
cmd = bubblewrap_cmd(generator, facts_dir, secrets_dir)
@@ -178,10 +178,10 @@ def _generate_facts_for_machine(
else:
machine_service_facts = machine.facts_data
for service in machine_service_facts:
for svc in machine_service_facts:
machine_updated |= generate_service_facts(
machine=machine,
service=service,
service=svc,
regenerate=regenerate,
secret_facts_store=machine.secret_facts_store,
public_facts_store=machine.public_facts_store,

View File

@@ -124,10 +124,8 @@ class SecretStore(SecretStoreBase):
os.umask(0o077)
for service in self.machine.facts_data:
for secret in self.machine.facts_data[service]["secret"]:
if isinstance(secret, dict):
secret_name = secret["name"]
else:
# TODO: drop old format soon
secret_name = secret
secret_name = (
secret["name"] if isinstance(secret, dict) else secret
) # TODO: drop old format soon
(output_dir / secret_name).write_bytes(self.get(service, secret_name))
(output_dir / ".pass_info").write_bytes(self.generate_hash())

View File

@@ -14,6 +14,9 @@ from clan_cli.completions import add_dynamic_completer, complete_machines
log = logging.getLogger(__name__)
# Constants for disk validation
EXPECTED_DISK_VALUES = 2
@dataclass
class FlashOptions:
@@ -44,7 +47,7 @@ class AppendDiskAction(argparse.Action):
if not (
isinstance(values, Sequence)
and not isinstance(values, str)
and len(values) == 2
and len(values) == EXPECTED_DISK_VALUES
):
msg = "Two values must be provided for a 'disk'"
raise ValueError(msg)
@@ -86,7 +89,7 @@ def flash_command(args: argparse.Namespace) -> None:
run_machine_flash(
machine=machine,
mode=opts.mode, # type: ignore
mode=opts.mode, # type: ignore[arg-type]
disks=opts.disks,
system_config=opts.system_config,
dry_run=opts.dry_run,

View File

@@ -1,3 +1,6 @@
import sys
# Implementation of OSC8
def hyperlink(text: str, url: str) -> str:
"""Generate OSC8 escape sequence for hyperlinks.
@@ -20,11 +23,7 @@ def hyperlink_same_text_and_url(url: str) -> str:
def help_hyperlink(description: str, url: str) -> str:
import sys
"""
Keep the description and the link the same to support legacy terminals.
"""
"""Keep the description and the link the same to support legacy terminals."""
if sys.argv[0].__contains__("docs.py"):
return docs_hyperlink(description, url)

View File

@@ -92,16 +92,21 @@ Examples:
"update-hardware-config",
help="Generate hardware specifics for a machine",
description="""
Generates hardware specifics for a machine. Such as the host platform, available kernel modules, etc.
This command will use kexec to boot the target into a minimal NixOS environment to gather the hardware information.
If you want to manually ssh into the target after this command use `ssh root@<ip> -i ~/.config/clan/nixos-anywhere/keys/id_ed25519`
The target must be a Linux based system reachable via SSH.
""",
epilog=(
"""
Examples:
$ clan machines update-hardware-config [MACHINE] [TARGET_HOST]
Will generate hardware specifics for the the specified `[TARGET_HOST]` and place the result in hardware.nix for the given machine `[MACHINE]`.
$ clan machines update-hardware-config [MACHINE] --target-host root@<ip>
Will generate the facter.json hardware report for `[TARGET_HOST]` and place the result in facter.json for the given machine `[MACHINE]`.
For more detailed information, visit: https://docs.clan.lol/guides/getting-started/configure/#machine-configuration

View File

@@ -44,6 +44,18 @@ def update_hardware_config_command(args: argparse.Namespace) -> None:
private_key=args.identity_file,
)
if not args.yes:
confirm = (
input(
f"Update hardware configuration for machine '{machine.name}' at '{target_host.target}'? [y/N]: "
)
.strip()
.lower()
)
if confirm not in ("y", "yes"):
log.info("Aborted.")
return
run_machine_hardware_info(opts, target_host)
@@ -56,11 +68,16 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None:
)
add_dynamic_completer(machine_parser, complete_machines)
parser.add_argument(
"target_host",
"--target-host",
type=str,
nargs="?",
help="ssh address to install to in the form of user@host:2222",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively.",
)
parser.add_argument(
"--host-key-check",
choices=list(get_args(HostKeyCheck)),

View File

@@ -3,15 +3,18 @@ import re
VALID_HOSTNAME = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", re.IGNORECASE)
# Maximum hostname/machine name length as per RFC specifications
MAX_HOSTNAME_LENGTH = 63
def validate_hostname(hostname: str) -> bool:
if len(hostname) > 63:
if len(hostname) > MAX_HOSTNAME_LENGTH:
return False
return VALID_HOSTNAME.match(hostname) is not None
def machine_name_type(arg_value: str) -> str:
if len(arg_value) > 63:
if len(arg_value) > MAX_HOSTNAME_LENGTH:
msg = "Machine name must be less than 63 characters long"
raise argparse.ArgumentTypeError(msg)
if not VALID_HOSTNAME.match(arg_value):

View File

@@ -1,6 +1,7 @@
import argparse
import logging
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.network.network import networks_from_flake
@@ -19,7 +20,8 @@ def list_command(args: argparse.Namespace) -> None:
col_network = max(12, *(len(name) for name in networks))
col_priority = 8
col_module = max(
10, *(len(net.module_name.split(".")[-1]) for net in networks.values())
10,
*(len(net.module_name.split(".")[-1]) for net in networks.values()),
)
col_running = 8
@@ -53,7 +55,7 @@ def list_command(args: argparse.Namespace) -> None:
try:
is_running = network.is_running()
running_status = "Yes" if is_running else "No"
except Exception:
except ClanError:
running_status = "Error"
print(

View File

@@ -10,6 +10,10 @@ from typing import Any
# Ensure you have a logger set up for logging exceptions
log = logging.getLogger(__name__)
# Constants for path trimming and profiler configuration
MAX_PATH_LEVELS = 4
explanation = """
cProfile Output Columns Explanation:
@@ -86,8 +90,8 @@ class ProfilerStore:
def trim_path_to_three_levels(path: str) -> str:
parts = path.split(os.path.sep)
if len(parts) > 4:
return os.path.sep.join(parts[-4:])
if len(parts) > MAX_PATH_LEVELS:
return os.path.sep.join(parts[-MAX_PATH_LEVELS:])
return path
@@ -100,7 +104,6 @@ def profile(func: Callable) -> Callable:
"""
def wrapper(*args: Any, **kwargs: Any) -> Any:
global PROFS
profiler = PROFS[func]
try:

View File

@@ -6,13 +6,6 @@ from pathlib import Path
from clan_lib.errors import ClanError
from clan_lib.git import commit_files
from clan_cli.completions import (
add_dynamic_completer,
complete_groups,
complete_machines,
complete_secrets,
complete_users,
)
from clan_cli.machines.types import machine_name_type, validate_hostname
from clan_cli.secrets.sops import load_age_plugins
@@ -238,6 +231,11 @@ def remove_machine_command(args: argparse.Namespace) -> None:
def add_group_argument(parser: argparse.ArgumentParser) -> None:
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_groups,
)
group_action = parser.add_argument(
"group",
help="the name of the secret",
@@ -336,6 +334,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machines to add",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(add_machine_action, complete_machines)
add_machine_parser.set_defaults(func=add_machine_command)
@@ -350,6 +353,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machines to remove",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(remove_machine_action, complete_machines)
remove_machine_parser.set_defaults(func=remove_machine_command)
@@ -361,6 +369,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user to add",
type=user_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_users,
)
add_dynamic_completer(add_user_action, complete_users)
add_user_parser.set_defaults(func=add_user_command)
@@ -375,6 +388,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user to remove",
type=user_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_users,
)
add_dynamic_completer(remove_user_action, complete_users)
remove_user_parser.set_defaults(func=remove_user_command)
@@ -389,6 +407,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the secret",
type=secret_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_secrets,
)
add_dynamic_completer(add_secret_action, complete_secrets)
add_secret_parser.set_defaults(func=add_secret_command)
@@ -403,5 +426,10 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the secret",
type=secret_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_secrets,
)
add_dynamic_completer(remove_secret_action, complete_secrets)
remove_secret_parser.set_defaults(func=remove_secret_command)

View File

@@ -7,12 +7,6 @@ from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.nix import nix_shell
from clan_cli.completions import (
add_dynamic_completer,
complete_groups,
complete_machines,
complete_users,
)
from clan_cli.secrets.sops import load_age_plugins
from .secrets import encrypt_secret, sops_secrets_folder
@@ -75,6 +69,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[],
help="the group to import the secrets to",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_groups,
)
add_dynamic_completer(group_action, complete_groups)
machine_action = parser.add_argument(
"--machine",
@@ -83,6 +82,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[],
help="the machine to import the secrets to",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(machine_action, complete_machines)
user_action = parser.add_argument(
"--user",
@@ -91,6 +95,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[],
help="the user to import the secrets to",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_users,
)
add_dynamic_completer(user_action, complete_users)
parser.add_argument(
"--prefix",

View File

@@ -4,11 +4,6 @@ from pathlib import Path
from clan_lib.flake import require_flake
from clan_lib.git import commit_files
from clan_cli.completions import (
add_dynamic_completer,
complete_machines,
complete_secrets,
)
from clan_cli.machines.types import machine_name_type, validate_hostname
from . import secrets, sops
@@ -177,6 +172,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(add_machine_action, complete_machines)
add_parser.add_argument(
"key",
@@ -192,6 +192,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(get_machine_parser, complete_machines)
get_parser.set_defaults(func=get_command)
@@ -202,6 +207,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(remove_machine_parser, complete_machines)
remove_parser.set_defaults(func=remove_command)
@@ -215,6 +225,12 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
complete_secrets,
)
add_dynamic_completer(machine_add_secret_parser, complete_machines)
add_secret_action = add_secret_parser.add_argument(
"secret",
@@ -234,6 +250,12 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine",
type=machine_name_type,
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
complete_secrets,
)
add_dynamic_completer(machine_remove_parser, complete_machines)
remove_secret_action = remove_secret_parser.add_argument(
"secret",

View File

@@ -12,14 +12,6 @@ from typing import IO
from clan_lib.errors import ClanError
from clan_lib.git import commit_files
from clan_cli.completions import (
add_dynamic_completer,
complete_groups,
complete_machines,
complete_secrets,
complete_users,
)
from . import sops
from .folders import (
list_objects,
@@ -39,6 +31,9 @@ from .types import VALID_SECRET_NAME, secret_name_type
log = logging.getLogger(__name__)
# Minimum number of keys required to keep a secret group
MIN_KEYS_FOR_GROUP_REMOVAL = 2
def list_generators_secrets(generators_path: Path) -> list[Path]:
paths: list[Path] = []
@@ -258,6 +253,11 @@ def add_secret_argument(parser: argparse.ArgumentParser, autocomplete: bool) ->
type=secret_name_type,
)
if autocomplete:
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_secrets,
)
add_dynamic_completer(secrets_parser, complete_secrets)
@@ -331,7 +331,7 @@ def disallow_member(
keys = collect_keys_for_path(group_folder.parent)
if len(keys) < 2:
if len(keys) < MIN_KEYS_FOR_GROUP_REMOVAL:
msg = f"Cannot remove {name} from {group_folder.parent.name}. No keys left. Use 'clan secrets remove {name}' to remove the secret."
raise ClanError(msg)
target.unlink()
@@ -465,6 +465,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[],
help="the group to import the secrets to (can be repeated)",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_groups,
)
add_dynamic_completer(set_group_action, complete_groups)
machine_parser = parser_set.add_argument(
"--machine",
@@ -473,6 +478,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[],
help="the machine to import the secrets to (can be repeated)",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_machines,
)
add_dynamic_completer(machine_parser, complete_machines)
set_user_action = parser_set.add_argument(
"--user",
@@ -481,6 +491,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[],
help="the user to import the secrets to (can be repeated)",
)
from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer,
complete_users,
)
add_dynamic_completer(set_user_action, complete_users)
parser_set.add_argument(
"-e",

View File

@@ -83,7 +83,7 @@ class KeyType(enum.Enum):
except FileNotFoundError:
return
except Exception as ex:
except OSError as ex:
log.warning(f"Could not read age keys from {key_path}", exc_info=ex)
if keys := os.environ.get("SOPS_AGE_KEY"):

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