Compare commits

..

107 Commits

Author SHA1 Message Date
Brian McGee
13ea536d46 fix(ui): blocking refresh of machine state after machine install 2025-09-05 09:26:39 +01:00
clan-bot
dba166cc8a Merge pull request 'Update nixpkgs-dev in devFlake' (#5097) from update-devFlake-nixpkgs-dev into main 2025-09-05 00:09:56 +00:00
clan-bot
21b872a1c9 Merge pull request 'Update nix-darwin' (#5096) from update-nix-darwin into main 2025-09-05 00:07:04 +00:00
clan-bot
be48ffe724 Update nixpkgs-dev in devFlake 2025-09-05 00:01:40 +00:00
clan-bot
7673b72991 Update nix-darwin 2025-09-05 00:00:43 +00:00
clan-bot
823114435a Merge pull request 'Update nixpkgs-dev in devFlake' (#5095) from update-devFlake-nixpkgs-dev into main 2025-09-04 15:08:37 +00:00
clan-bot
e7efbb701b Update nixpkgs-dev in devFlake 2025-09-04 15:01:35 +00:00
hsjobeki
30d9c86015 Merge pull request 'ui/move: fix bug, with interleaving positions' (#5094) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5094
2025-09-03 20:00:53 +00:00
Johannes Kirschbauer
313b77be79 ui/move: fix bug, with interleaving positions 2025-09-03 21:57:27 +02:00
hsjobeki
6229e62281 Merge pull request 'ui/services: fix reactivity issue when switching between services' (#5093) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5093
2025-09-03 19:53:56 +00:00
Johannes Kirschbauer
49ff4da6be ui/services: fix reactivity issue when switching between services 2025-09-03 21:50:15 +02:00
hsjobeki
6d6521803d Merge pull request 'ui/move: fix bug, when long press without moving' (#5092) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5092
2025-09-03 19:44:14 +00:00
Johannes Kirschbauer
afd7bfc8c0 ui/move: fix bug, when long press without moving 2025-09-03 21:40:46 +02:00
hsjobeki
88fa3dff83 Merge pull request 'ui/3d-fonts: replace troika with 3d rendered default font' (#5091) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5091
2025-09-03 19:38:05 +00:00
Johannes Kirschbauer
629ef65ce5 ui/3d-fonts: replace troika with 3d rendered default font 2025-09-03 21:34:26 +02:00
Johannes Kirschbauer
92151331f3 ui/devShell: remove fonts directory if exists 2025-09-03 21:33:40 +02:00
Johannes Kirschbauer
67dcd45dd5 ui/services: simplify and sort 2025-09-03 21:15:06 +02:00
hsjobeki
95a4a69ffb Merge pull request 'ui/fieldset: use normal div, due to webkit layout bug for fieldsets' (#5090) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5090
2025-09-03 19:11:47 +00:00
Johannes Kirschbauer
88343ce523 ui/sidebar: remove spurious console.log 2025-09-03 21:05:58 +02:00
Johannes Kirschbauer
fd9dd6f872 ui/fieldset: use normal div, due to webkit layout bug for fieldsets 2025-09-03 21:05:35 +02:00
Brian McGee
aaaa310c7f feat(ui): refine input to allow start and end components 2025-09-03 21:05:35 +02:00
Luis Hebendanz
ffbf22eb60 Merge pull request 'docs: Fixup out of date networking docs' (#5089) from Qubasa/clan-core:fix_docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5089
2025-09-03 16:39:22 +00:00
Qubasa
8d3e0d2209 docs: Fixup out of date networking docs 2025-09-03 18:35:55 +02:00
Luis Hebendanz
c05a890d50 Merge pull request 'clanServices: telegraf -> fix telegraf-json failing because file does not yet exist' (#5088) from Qubasa/clan-core:telegraf_fix into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5088
2025-09-03 16:08:07 +00:00
Qubasa
03458ffbd8 clanServices: telegraf -> fix telegraf-json failing because file does not yet exist 2025-09-03 17:51:51 +02:00
clan-bot
ea098048c8 Merge pull request 'Update nixpkgs-dev in devFlake' (#5086) from update-devFlake-nixpkgs-dev into main 2025-09-03 15:07:41 +00:00
brianmcgee
838ed6ead7 Merge pull request 'feat(ui): refine input to allow start and end components' (#5080) from ui/password-input-reveal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5080
2025-09-03 15:02:30 +00:00
clan-bot
7e7278b99b Update nixpkgs-dev in devFlake 2025-09-03 15:01:36 +00:00
Brian McGee
f4d7728f3f feat(ui): refine input to allow start and end components 2025-09-03 15:55:49 +01:00
brianmcgee
c9b71496eb Merge pull request 'feat(ui): improve placeholder to MachineTags' (#5085) from ui/improve-tags-placeholder into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5085
2025-09-03 14:42:14 +00:00
Brian McGee
cd1f9c5a8b feat(ui): improve placeholder to MachineTags 2025-09-03 15:38:57 +01:00
hsjobeki
56379510d0 Merge pull request 'ui/sidebar: max-width of section, scroll within sections' (#5083) from ui/update-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5083
2025-09-03 12:49:22 +00:00
Johannes Kirschbauer
389299ac7d ui/refetch: don't block button clicks, move context out of modal 2025-09-03 14:45:55 +02:00
Johannes Kirschbauer
9cf04bcb5f ui/services: pass instance to ServiceRoute 2025-09-03 14:31:42 +02:00
Johannes Kirschbauer
c370598564 ui/sidebar: max-width of section, scroll within sections 2025-09-03 14:31:05 +02:00
brianmcgee
04001ff178 Merge pull request 'feat(ui): refresh state after machine install or update' (#5081) from ui/refresh-state into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5081
2025-09-03 11:22:15 +00:00
Brian McGee
194c3080ea feat(ui): refresh state after machine install or update
Closes #5071
2025-09-03 12:10:35 +01:00
hsjobeki
60d1e524ac Merge pull request 'ui/update: integrate with api' (#5079) from ui/update-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5079
2025-09-03 10:46:45 +00:00
Johannes Kirschbauer
672af1c63d ui/update: fix cancel/close 2025-09-03 12:41:26 +02:00
Johannes Kirschbauer
6cb728a4ca ui/update: integrate with api 2025-09-03 12:29:23 +02:00
hsjobeki
a074650947 Merge pull request 'ui/install: vars fix loading screen' (#5077) from ui/update-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5077
2025-09-03 06:34:29 +00:00
Johannes Kirschbauer
f169a40c69 ui/install: fix onClose not called 2025-09-03 08:30:57 +02:00
Johannes Kirschbauer
480d5ee18c ui/install: vars fix loading screen 2025-09-03 08:28:51 +02:00
hsjobeki
ba47d797e4 Merge pull request 'ui/update: init update machine' (#5076) from ui/update-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5076
2025-09-02 20:18:01 +00:00
Johannes Kirschbauer
3e5f84dcb4 ui/update: init update machine 2025-09-02 22:14:34 +02:00
brianmcgee
e398d98b42 Merge pull request 'fix(ui): re-enable machine state query but disable polling' (#5075) from ui/fix-machine-status into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5075
2025-09-02 20:13:22 +00:00
Brian McGee
09e5f78aae fix(ui): re-enable machine state query but disable polling 2025-09-02 21:09:13 +01:00
clan-bot
ae1680a720 Merge pull request 'Update nixpkgs-dev in devFlake' (#5074) from update-devFlake-nixpkgs-dev into main 2025-09-02 20:07:56 +00:00
clan-bot
9abf557353 Update nixpkgs-dev in devFlake 2025-09-02 20:01:33 +00:00
brianmcgee
dc0ec3443e Merge pull request 'feat(ui): simplify machine status' (#5073) from ui/update-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5073
2025-09-02 19:28:09 +00:00
Brian McGee
d6c6918f85 feat(ui): simplify machine status 2025-09-02 21:20:25 +02:00
hsjobeki
24756442c8 Merge pull request 'feat(ui): services in sidebar and sidebar pane' (#5072) from ui/list-services-sidebar into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5072
2025-09-02 19:16:49 +00:00
Johannes Kirschbauer
c61a0f0712 ui/services: wire up with sidebar and router 2025-09-02 21:13:10 +02:00
Johannes Kirschbauer
f05bfcb13d ui/services: refactor services 2025-09-02 20:41:51 +02:00
Brian McGee
6d8ea1f2c5 feat(ui): services in sidebar and sidebar pane 2025-09-02 20:39:24 +02:00
Luis Hebendanz
f1de0e28ff Merge pull request 'clan-app: Add password input' (#5068) from Qubasa/clan-core:password_prompt into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5068
2025-09-02 15:48:24 +00:00
Qubasa
53ce3cf53d clan-app: Add password input 2025-09-02 17:41:07 +02:00
brianmcgee
0ac6d7be87 Merge pull request 'fix(ui): add loader sizes' (#5067) from ui/fix-loader-scaling into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5067
2025-09-02 15:09:42 +00:00
Brian McGee
e55401ecd9 fix(ui): add loader sizes 2025-09-02 16:04:41 +01:00
DavHau
37a49a14f4 vars: fix re-generate behavior for dependencies of shared vars (#5001)
fixes https://git.clan.lol/clan/clan-core/issues/3791

This fixes multiple issues we had when re-generating shared vars.

Problem 1: shared vars are re-generated for each individual machine instead of just once (see #3791)

Problem 2: When a shared var was re-generated for one machine, dependent vars on other machines did not get re-generated, leading to broken state

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5001
2025-09-02 14:54:24 +00:00
brianmcgee
7f68b10611 Merge pull request 'fix(ui): remove empty strings from add machine api call' (#5066) from ui/refine-add-machine-api-call into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5066
2025-09-02 14:43:57 +00:00
Brian McGee
a2867ba29d fix(ui): remove empty strings from add machine api call 2025-09-02 15:40:32 +01:00
pinpox
0817cf868b Merge pull request 'Change default coredns port' (#5065) from fixes-coredns into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5065
2025-09-02 13:28:57 +00:00
pinpox
018ffdaeeb Change default coredns port
This removes a conflict with systemd-resolved and provides an option to
set your own port
2025-09-02 15:23:12 +02:00
hsjobeki
eebb9b6a12 Merge pull request 'ui/fix: some more fixes' (#5063) from ui-more-3 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5063
2025-09-02 12:12:25 +00:00
Johannes Kirschbauer
36f73d40b3 ui/scene: fix double click on move 2025-09-02 14:09:16 +02:00
Johannes Kirschbauer
db84369000 ui/toolbar: shrink width avoid blocking interactions 2025-09-02 14:08:48 +02:00
Johannes Kirschbauer
359b2d4e7a ui/fix: move machine into starting position 2025-09-02 12:40:44 +02:00
Johannes Kirschbauer
2af9bd5003 ui/fix: frozen map after clicking machine 2025-09-02 12:40:23 +02:00
Johannes Kirschbauer
a8cbfcbd18 ui/toolbar: increase stacking index 2025-09-02 12:38:42 +02:00
Johannes Kirschbauer
dc17d62131 ui/contextMenu: prevent duplicate context menu 2025-09-02 12:38:15 +02:00
lassulus
f97e22e125 Merge pull request 'fix: network checking triggering fail2ban' (#5047) from MoritzBoehme/clan-core:fix-network-check into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5047
2025-09-02 09:49:33 +00:00
hsjobeki
1d9ad2ae54 Merge pull request 'ui/labels: fix font, bg radius' (#5061) from ui-more-3 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5061
2025-09-02 09:38:38 +00:00
pinpox
c266261d3b Merge pull request 'Add certificates service' (#4780) from certificates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4780
2025-09-02 09:36:18 +00:00
Johannes Kirschbauer
93c31d4c26 ui/labels: fix font, bg radius 2025-09-02 11:34:55 +02:00
pinpox
c9275db377 update vars 2025-09-02 11:27:31 +02:00
brianmcgee
cf83833d8b Merge pull request 'fix(ui): reactivity within machine detail view' (#5060) from ui/fix-machine-detail into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5060
2025-09-02 08:05:03 +00:00
Brian McGee
494f79edb4 fix(ui): reactivity within machine detail view 2025-09-02 09:01:23 +01:00
clan-bot
de3102614a Merge pull request 'Update flake-parts' (#5059) from update-flake-parts into main 2025-09-02 00:06:18 +00:00
clan-bot
a6f0924c05 Update flake-parts 2025-09-02 00:00:42 +00:00
pinpox
99dc4f6787 Fix update-vars script 2025-09-01 22:31:09 +02:00
hsjobeki
5f2ad6432e Merge pull request 'ui/machines: some scenen improvements' (#5058) from ui-more-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5058
2025-09-01 18:52:20 +00:00
Johannes Kirschbauer
f8c34caaab ui/machines: add background to 3d labels 2025-09-01 20:48:02 +02:00
hsjobeki
8c2399446b Merge pull request 'ui/machineTags: remove spurious console.logs' (#5056) from ui-more-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5056
2025-09-01 18:39:11 +00:00
Johannes Kirschbauer
95c781bf4d ui/machines: move on long press 2025-09-01 19:14:32 +02:00
Johannes Kirschbauer
fe58de0997 ui/machineTags: remove spurious logging 2025-09-01 18:23:39 +02:00
hsjobeki
7582458bae Merge pull request 'ui/machineTags: fix keyboard and select logic' (#5055) from ui-more-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5055
2025-09-01 16:20:37 +00:00
Johannes Kirschbauer
3a7d7afaab ui/machineTags: fix keyboard and select logic 2025-09-01 18:15:48 +02:00
Luis Hebendanz
321eeacff0 Merge pull request 'clan-app: Now displays runtime icon correctly in process overview' (#5019) from Qubasa/clan-core:fix_runtime_icon into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5019
2025-09-01 15:37:51 +00:00
Qubasa
8ae43ff9a0 clan-app: display runtime icon on macOS too 2025-09-01 17:34:30 +02:00
Qubasa
e6efd5e731 clan-app: display runtime icon correctly in process overview 2025-09-01 17:34:07 +02:00
clan-bot
7c1c8a5486 Merge pull request 'Update nuschtos in devFlake' (#5053) from update-devFlake-nuschtos into main 2025-09-01 15:10:50 +00:00
clan-bot
7932562fa6 Merge pull request 'Update nixpkgs-dev in devFlake' (#5052) from update-devFlake-nixpkgs-dev into main 2025-09-01 15:10:24 +00:00
clan-bot
ac22843abc Merge pull request 'Update disko' (#5051) from update-disko into main 2025-09-01 15:10:23 +00:00
clan-bot
eb83386098 Update nuschtos in devFlake 2025-09-01 15:01:33 +00:00
clan-bot
7877075847 Update nixpkgs-dev in devFlake 2025-09-01 15:01:30 +00:00
clan-bot
7206dd8219 Update disko 2025-09-01 15:00:34 +00:00
hsjobeki
f21e1e7641 Merge pull request 'api/machines: move configuration data into subattribute' (#5048) from api-list-machines into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5048
2025-09-01 14:55:26 +00:00
Johannes Kirschbauer
c2a3f5e498 api/machines: populate instance_refs 2025-09-01 16:47:47 +02:00
pinpox
63c0db482f rename TLDs to tlds 2025-09-01 15:49:53 +02:00
pinpox
d2456be3dd Add certificates service 2025-09-01 15:49:53 +02:00
pinpox
c3c08482ac Merge pull request 'Fix update-vars, add shell' (#5050) from fix-update-vars-new into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5050
2025-09-01 13:38:25 +00:00
brianmcgee
62126f0c32 Merge pull request 'feat(ui): refine styling for MachineTags and fix inverted mode' (#5049) from ui/refine-machine-tags into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5049
2025-09-01 13:32:06 +00:00
pinpox
28139560c2 Fix update-vars, add shell 2025-09-01 15:31:54 +02:00
Brian McGee
45c916fb6d feat(ui): refine styling for MachineTags and fix inverted mode
Closes #5045
2025-09-01 14:27:47 +01:00
Johannes Kirschbauer
727d4e70ae api/machines: move configuration data into subattribute
This helps to make room for 'instance_refs'
And potentially other metadata that we want to compute and expose
2025-09-01 14:42:12 +02:00
Moritz Böhme
261c5d2be8 fix: network checking triggering fail2ban 2025-09-01 14:17:10 +02:00
127 changed files with 3936 additions and 1273 deletions

View File

@@ -1,6 +0,0 @@
{ fetchgit }:
fetchgit {
url = "https://git.clan.lol/clan/clan-core.git";
rev = "5d884cecc2585a29b6a3596681839d081b4de192";
sha256 = "09is1afmncamavb2q88qac37vmsijxzsy1iz1vr6gsyjq2rixaxc";
}

View File

@@ -50,6 +50,7 @@
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
] ]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; closureInfo = pkgs.closureInfo { rootPaths = dependencies; };

View File

@@ -0,0 +1,10 @@
system:
builtins.fetchurl {
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${system}.json";
sha256 =
{
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
}
.${system};
}

View File

@@ -18,27 +18,23 @@
fileSystems."/".device = lib.mkDefault "/dev/vda"; fileSystems."/".device = lib.mkDefault "/dev/vda";
boot.loader.grub.device = lib.mkDefault "/dev/vda"; boot.loader.grub.device = lib.mkDefault "/dev/vda";
imports = [ self.nixosModules.test-install-machine-without-system ]; imports = [
self.nixosModules.test-install-machine-without-system
];
}; };
clan.machines.test-install-machine-with-system = clan.machines.test-install-machine-with-system =
{ pkgs, ... }: { pkgs, ... }:
{ {
# https://git.clan.lol/clan/test-fixtures # https://git.clan.lol/clan/test-fixtures
facter.reportPath = builtins.fetchurl { facter.reportPath = import ./facter-report.nix pkgs.hostPlatform.system;
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${pkgs.hostPlatform.system}.json";
sha256 =
{
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
}
.${pkgs.hostPlatform.system};
};
fileSystems."/".device = lib.mkDefault "/dev/vda"; fileSystems."/".device = lib.mkDefault "/dev/vda";
boot.loader.grub.device = lib.mkDefault "/dev/vda"; boot.loader.grub.device = lib.mkDefault "/dev/vda";
imports = [ self.nixosModules.test-install-machine-without-system ]; imports = [ self.nixosModules.test-install-machine-without-system ];
}; };
flake.nixosModules = { flake.nixosModules = {
test-install-machine-without-system = test-install-machine-without-system =
{ lib, modulesPath, ... }: { lib, modulesPath, ... }:
@@ -159,6 +155,7 @@
pkgs.stdenv.drvPath pkgs.stdenv.drvPath
pkgs.bash.drvPath pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir pkgs.buildPackages.xorg.lndir
(import ./facter-report.nix pkgs.hostPlatform.system)
] ]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
}; };

View File

@@ -35,6 +35,7 @@
pkgs.stdenv.drvPath pkgs.stdenv.drvPath
pkgs.stdenvNoCC pkgs.stdenvNoCC
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
] ]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; closureInfo = pkgs.closureInfo { rootPaths = dependencies; };

View File

@@ -112,6 +112,7 @@
pkgs.stdenv.drvPath pkgs.stdenv.drvPath
pkgs.bash.drvPath pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir pkgs.buildPackages.xorg.lndir
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
] ]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
}; };

View File

@@ -0,0 +1,32 @@
This service sets up a certificate authority (CA) that can issue certificates to
other machines in your clan. For this the `ca` role is used.
It additionally provides a `default` role, that can be applied to all machines
in your clan and will make sure they trust your CA.
## Example Usage
The following configuration would add a CA for the top level domain `.foo`. If
the machine `server` now hosts a webservice at `https://something.foo`, it will
get a certificate from `ca` which is valid inside your clan. The machine
`client` will trust this certificate if it makes a request to
`https://something.foo`.
This clan service can be combined with the `coredns` service for easy to deploy,
SSL secured clan-internal service hosting.
```nix
inventory = {
machines.ca = { };
machines.client = { };
machines.server = { };
instances."certificates" = {
module.name = "certificates";
module.input = "self";
roles.ca.machines.ca.settings.tlds = [ "foo" ];
roles.default.machines.client = { };
roles.default.machines.server = { };
};
};
```

View File

@@ -0,0 +1,245 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "certificates";
manifest.description = "Sets up a certificates internal to your Clan";
manifest.categories = [ "Network" ];
manifest.readme = builtins.readFile ./README.md;
roles.ca = {
interface =
{ lib, ... }:
{
options.acmeEmail = lib.mkOption {
type = lib.types.str;
default = "none@none.tld";
description = ''
Email address for account creation and correspondence from the CA.
It is recommended to use the same email for all certs to avoid account
creation limits.
'';
};
options.tlds = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Top level domain for this CA. Certificates will be issued and trusted for *.<tld>";
};
options.expire = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "When the certificate should expire.";
default = "8760h";
example = "8760h";
};
};
perInstance =
{ settings, ... }:
{
nixosModule =
{
config,
pkgs,
lib,
...
}:
let
domains = map (tld: "ca.${tld}") settings.tlds;
in
{
security.acme.defaults.email = settings.acmeEmail;
security.acme = {
certs = builtins.listToAttrs (
map (domain: {
name = domain;
value = {
server = "https://${domain}:1443/acme/acme/directory";
};
}) domains
);
};
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
enable = true;
recommendedProxySettings = true;
virtualHosts = builtins.listToAttrs (
map (domain: {
name = domain;
value = {
addSSL = true;
enableACME = true;
locations."/".proxyPass = "https://localhost:1443";
locations."= /ca.crt".alias =
config.clan.core.vars.generators.step-intermediate-cert.files."intermediate.crt".path;
};
}) domains
);
};
clan.core.vars.generators = {
# Intermediate key generator
"step-intermediate-key" = {
files."intermediate.key" = {
secret = true;
deploy = true;
owner = "step-ca";
group = "step-ca";
};
runtimeInputs = [ pkgs.step-cli ];
script = ''
step crypto keypair --kty EC --curve P-256 --no-password --insecure $out/intermediate.pub $out/intermediate.key
'';
};
# Intermediate certificate generator
"step-intermediate-cert" = {
files."intermediate.crt".secret = false;
dependencies = [
"step-ca"
"step-intermediate-key"
];
runtimeInputs = [ pkgs.step-cli ];
script = ''
# Create intermediate certificate
step certificate create \
--ca $in/step-ca/ca.crt \
--ca-key $in/step-ca/ca.key \
--ca-password-file /dev/null \
--key $in/step-intermediate-key/intermediate.key \
--template ${pkgs.writeText "intermediate.tmpl" ''
{
"subject": {{ toJson .Subject }},
"keyUsage": ["certSign", "crlSign"],
"basicConstraints": {
"isCA": true,
"maxPathLen": 0
},
"nameConstraints": {
"critical": true,
"permittedDNSDomains": [${
(lib.strings.concatStringsSep "," (map (tld: ''"${tld}"'') settings.tlds))
}]
}
}
''} ${lib.optionalString (settings.expire != null) "--not-after ${settings.expire}"} \
--not-before=-12h \
--no-password --insecure \
"Clan Intermediate CA" \
$out/intermediate.crt
'';
};
};
services.step-ca = {
enable = true;
intermediatePasswordFile = "/dev/null";
address = "0.0.0.0";
port = 1443;
settings = {
root = config.clan.core.vars.generators.step-ca.files."ca.crt".path;
crt = config.clan.core.vars.generators.step-intermediate-cert.files."intermediate.crt".path;
key = config.clan.core.vars.generators.step-intermediate-key.files."intermediate.key".path;
dnsNames = domains;
logger.format = "text";
db = {
type = "badger";
dataSource = "/var/lib/step-ca/db";
};
authority = {
provisioners = [
{
type = "ACME";
name = "acme";
forceCN = true;
}
];
claims = {
maxTLSCertDuration = "2160h";
defaultTLSCertDuration = "2160h";
};
backdate = "1m0s";
};
tls = {
cipherSuites = [
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
];
minVersion = 1.2;
maxVersion = 1.3;
renegotiation = false;
};
};
};
};
};
};
# Empty role, so we can add non-ca machins to the instance to trust the CA
roles.default = {
interface =
{ lib, ... }:
{
options.acmeEmail = lib.mkOption {
type = lib.types.str;
default = "none@none.tld";
description = ''
Email address for account creation and correspondence from the CA.
It is recommended to use the same email for all certs to avoid account
creation limits.
'';
};
};
perInstance =
{ settings, ... }:
{
nixosModule.security.acme.defaults.email = settings.acmeEmail;
};
};
# All machines (independent of role) will trust the CA
perMachine.nixosModule =
{ pkgs, config, ... }:
{
# Root CA generator
clan.core.vars.generators = {
"step-ca" = {
share = true;
files."ca.key" = {
secret = true;
deploy = false;
};
files."ca.crt".secret = false;
runtimeInputs = [ pkgs.step-cli ];
script = ''
step certificate create --template ${pkgs.writeText "root.tmpl" ''
{
"subject": {{ toJson .Subject }},
"issuer": {{ toJson .Subject }},
"keyUsage": ["certSign", "crlSign"],
"basicConstraints": {
"isCA": true,
"maxPathLen": 1
}
}
''} "Clan Root CA" $out/ca.crt $out/ca.key \
--kty EC --curve P-256 \
--not-after=8760h \
--not-before=-12h \
--no-password --insecure
'';
};
};
security.pki.certificateFiles = [ config.clan.core.vars.generators."step-ca".files."ca.crt".path ];
environment.systemPackages = [ pkgs.openssl ];
security.acme.acceptTerms = true;
};
}

View File

@@ -0,0 +1,21 @@
{
self,
lib,
...
}:
let
module = lib.modules.importApply ./default.nix {
inherit (self) packages;
};
in
{
clan.modules.certificates = module;
perSystem =
{ ... }:
{
clan.nixosTests.certificates = {
imports = [ ./tests/vm/default.nix ];
clan.modules.certificates = module;
};
};
}

View File

@@ -0,0 +1,84 @@
{
name = "certificates";
clan = {
directory = ./.;
inventory = {
machines.ca = { }; # 192.168.1.1
machines.client = { }; # 192.168.1.2
machines.server = { }; # 192.168.1.3
instances."certificates" = {
module.name = "certificates";
module.input = "self";
roles.ca.machines.ca.settings.tlds = [ "foo" ];
roles.default.machines.client = { };
roles.default.machines.server = { };
};
};
};
nodes =
let
hostConfig = ''
192.168.1.1 ca.foo
192.168.1.3 test.foo
'';
in
{
client.networking.extraHosts = hostConfig;
ca.networking.extraHosts = hostConfig;
server = {
networking.extraHosts = hostConfig;
# TODO: Could this be set automatically?
# I would like to get this information from the coredns module, but we
# cannot model dependencies yet
security.acme.certs."test.foo".server = "https://ca.foo/acme/acme/directory";
# Host a simple service on 'server', with SSL provided via our CA. 'client'
# should be able to curl it via https and accept the certificates
# presented
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
enable = true;
virtualHosts."test.foo" = {
enableACME = true;
forceSSL = true;
locations."/" = {
return = "200 'test server response'";
extraConfig = "add_header Content-Type text/plain;";
};
};
};
};
};
testScript = ''
start_all()
import time
time.sleep(3)
ca.succeed("systemctl restart acme-order-renew-ca.foo.service ")
time.sleep(3)
server.succeed("systemctl restart acme-test.foo.service")
# It takes a while for the correct certs to appear (before that self-signed
# are presented by nginx) so we wait for a bit.
client.wait_until_succeeds("curl -v https://test.foo")
# Show certificate information for debugging
client.succeed("openssl s_client -connect test.foo:443 -servername test.foo </dev/null 2>/dev/null | openssl x509 -text -noout 1>&2")
'';
}

View File

@@ -0,0 +1,6 @@
[
{
"publickey": "age1yd2cden7jav8x4nzx2fwze2fsa5j0qm2m3t7zum765z3u4gj433q7dqj43",
"type": "age"
}
]

View File

@@ -0,0 +1,6 @@
[
{
"publickey": "age1js225d8jc507sgcg0fdfv2x3xv3asm4ds5c6s4hp37nq8spxu95sc5x3ce",
"type": "age"
}
]

View File

@@ -0,0 +1,6 @@
[
{
"publickey": "age1nwuh8lc604mnz5r8ku8zswyswnwv02excw237c0cmtlejp7xfp8sdrcwfa",
"type": "age"
}
]

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:6+XilULKRuWtAZ6B8Lj9UqCfi1T6dmqrDqBNXqS4SvBwM1bIWiL6juaT1Q7ByOexzID7tY740gmQBqTey54uLydh8mW0m4ZtUqw=,iv:9kscsrMPBGkutTnxrc5nrc7tQXpzLxw+929pUDKqTu0=,tag:753uIjm8ZRs0xsjiejEY8g==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1d3kycldZRXhmR0FqTXJp\nWWU0MDBYNmxxbFE5M2xKYm5KWnQ0MXBHNEM4CjN4RFFVcFlkd3pjTFVDQ3Vackdj\nVTVhMWoxdFpsWHp5S1p4L05kYk5LUkkKLS0tIENtZFZZTjY2amFVQmZLZFplQzBC\nZm1vWFI4MXR1ZHIxTTQ5VXdSYUhvOTQKte0bKjXQ0xA8FrpuChjDUvjVqp97D8kT\n3tVh6scdjxW48VSBZP1GRmqcMqCdj75GvJTbWeNEV4PDBW7GI0UW+Q==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-02T08:42:39Z",
"mac": "ENC[AES256_GCM,data:AftMorrH7qX5ctVu5evYHn5h9pC4Mmm2VYaAV8Hy0PKTc777jNsL6DrxFVV3NVqtecpwrzZFWKgzukcdcRJe4veVeBrusmoZYtifH0AWZTEVpVlr2UXYYxCDmNZt1WHfVUo40bT//X6QM0ye6a/2Y1jYPbMbryQNcGmnpk9PDvU=,iv:5nk+d8hzA05LQp7ZHRbIgiENg2Ha6J6YzyducM6zcNU=,tag:dy1hqWVzMu/+fSK57h9ZCA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -0,0 +1 @@
../../../users/admin

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:jdTuGQUYvT1yXei1RHKsOCsABmMlkcLuziHDVhA7NequZeNu0fSbrJTXQDCHsDGhlYRcjU5EsEDT750xdleXuD3Gs9zWvPVobI4=,iv:YVow3K1j6fzRF9bRfIEpuOkO/nRpku/UQxWNGC+UJQQ=,tag:cNLM5R7uu6QpwPB9K6MYzg==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvOVF2WXRSL0NpQzFZR01I\nNU85TGcyQmVDazN1dmpuRFVTZEg5NDRKTGhrCk1IVjFSU1V6WHBVRnFWcHkyVERr\nTjFKbW1mQ2FWOWhjN2VPamMxVEQ5VkkKLS0tIENVUGlhanhuWGtDKzBzRmk2dE4v\nMXZBRXNMa3IrOTZTNHRUWVE3UXEwSWMK2cBLoL/H/Vxd/klVrqVLdX9Mww5j7gw/\nEWc5/hN+km6XoW+DiJxVG4qaJ7qqld6u5ZnKgJT+2h9CfjA04I2akg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-02T08:42:51Z",
"mac": "ENC[AES256_GCM,data:zOBQVM2Ydu4v0+Fw3p3cEU+5+7eKaadV0tKro1JVOxclG1Vs6Myq57nw2eWf5JxIl0ulL+FavPKY26qOQ3aqcGOT3PMRlCda9z+0oSn9Im9bE/DzAGmoH/bp76kFkgTTOCZTMUoqJ+UJqv0qy1BH/92sSSKmYshEX6d1vr5ISrw=,iv:i9ZW4sLxOCan4UokHlySVr1CW39nCTusG4DmEPj/gIw=,tag:iZBDPHDkE3Vt5mFcFu1TPQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -0,0 +1 @@
../../../users/admin

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:5CJuHcxJMXZJ8GqAeG3BrbWtT1kade4kxgJsn1cRpmr1UgN0ZVYnluPEiBscClNSOzcc6vcrBpfTI3dj1tASKTLP58M+GDBFQDo=,iv:gsK7XqBGkYCoqAvyFlIXuJ27PKSbTmy7f6cgTmT2gow=,tag:qG5KejkBvy9ytfhGXa/Mnw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxbzVqYkplTzJKN1pwS3VM\naFFIK2VsR3lYUVExYW9ieERBL0tlcFZtVzJRCkpiLzdmWmFlOUZ5QUJ4WkhXZ2tQ\nZm92YXBCV0RpYnIydUdEVTRiamI4bjAKLS0tIG93a2htS1hFcjBOeVFnNCtQTHVr\na2FPYjVGbWtORjJVWXE5bndPU1RWcXMKikMEB7X+kb7OtiyqXn3HRpLYkCdoayDh\n7cjGnplk17q25/lRNHM4JVS5isFfuftCl01enESqkvgq+cwuFwa9DQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-02T08:42:59Z",
"mac": "ENC[AES256_GCM,data:xybV2D0xukZnH2OwRpIugPnS7LN9AbgGKwFioPJc1FQWx9TxMUVDwgMN6V5WrhWkXgF2zP4krtDYpEz4Vq+LbOjcnTUteuCc+7pMHubuRuip7j+M32MH1kuf4bVZuXbCfvm7brGxe83FzjoioLqzA8g/X6Q1q7/ErkNeFjluC3Q=,iv:QEW3EUKSRZY3fbXlP7z+SffWkQeXwMAa5K8RQW7NvPE=,tag:DhFxY7xr7H1Wbd527swD0Q==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -0,0 +1 @@
../../../users/admin

View File

@@ -0,0 +1,4 @@
{
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"type": "age"
}

View File

@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBsDCCAVegAwIBAgIQbT1Ivm+uwyf0HNkJfan2BTAKBggqhkjOPQQDAjAXMRUw
EwYDVQQDEwxDbGFuIFJvb3QgQ0EwHhcNMjUwOTAxMjA0MzAzWhcNMjYwOTAyMDg0
MzAzWjAfMR0wGwYDVQQDExRDbGFuIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABDXCNrUIotju9P1U6JxLV43sOxLlRphQJS4dM+lvjTZc
aQ+HwQg0AHVlQNRwS3JqKrJJtJVyKbZklh6eFaDPoj6jfTB7MA4GA1UdDwEB/wQE
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRKHaccHgP2ccSWVBWN
zGoDdTg7aTAfBgNVHSMEGDAWgBSfsnz4phMJx9su/kgeF/FbZQCBgzAVBgNVHR4B
Af8ECzAJoAcwBYIDZm9vMAoGCCqGSM49BAMCA0cAMEQCICiUDk1zGNzpS/iVKLfW
zUGaCagpn2mCx4xAXQM9UranAiAn68nVYGWjkzhU31wyCAupxOjw7Bt96XXqIAz9
hLLtMA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1 @@
../../../../../../sops/machines/ca

View File

@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:Auonh9fa7jSkld1Zyxw74x5ydj6Xc+0SOgiqumVETNCfner9K96Rmv1PkREuHNGWPsnzyEM3pRT8ijvu3QoKvy9QPCCewyT07Wqe4G74+bk1iMeAHsV3To6kHs6M8OISvE+CmG0+hlLmdfRSabTzyWPLHbOjvFTEEuA5G7xiryacSYOE++eeEHdn+oUDh/IMTcfLjCGMjsXFikx1Hb+ofeRTlCg47+0w4MXVvQkOzQB5V2C694jZXvZ19jd/ioqr8YASz2xatGvqwW6cpZxqOWyZJ0UAj/6yFk6tZWifqVB3wgU=,iv:ITFCrDkeWl4GWCebVq15ei9QmkOLDwUIYojKZ2TU6JU=,tag:8k4iYbCIusUykY79H86WUQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsT25UbjJTQ2tzbnQyUm9p\neWx1UlZIeVpocnBqUCt0YnFlN2FOU25Lb0hNCmdXUUsyalRTbHRRQ0NLSGc1YllV\nUXRwaENhaXU1WmdnVDE0UWprUUUyeDAKLS0tIHV3dHU3aG5JclM0V3FadzN0SU14\ndFptbEJUNXQ4QVlqbkJ1TjAvdDQwSGsKcKPWUjhK7wzIpdIdksMShF2fpLdDTUBS\nZiU7P1T+3psxad9qhapvU0JrAY+9veFaYVEHha2aN/XKs8HqUcTp3A==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1yd2cden7jav8x4nzx2fwze2fsa5j0qm2m3t7zum765z3u4gj433q7dqj43",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZFVteVZwVGVmRE9NT3hG\nNGMyS3FSaXluM1FpeUp6SDVMUEpwYzg5SmdvCkRPU0QyU1JicGNkdlMyQWVkT0k3\nL2YrbDhWeGk4WFhxcUFmTmhZQ0pEQncKLS0tIG85Ui9rKzBJQ2VkMFBUQTMvSTlu\nbm8rZ09Wa24rQkNvTTNtYTZBN3MrZlkK7cjNhlUKZdOrRq/nKUsbUQgNTzX8jO+0\nzADpz6WCMvsJ15xazc10BGh03OtdMWl5tcoWMaZ71HWtI9Gip5DH0w==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-02T08:42:42Z",
"mac": "ENC[AES256_GCM,data:9xlO5Yis8DG/y8GjvP63NltD4xEL7zqdHL2cQE8gAoh/ZamAmK5ZL0ld80mB3eIYEPKZYvmUYI4Lkrge2ZdqyDoubrW+eJ3dxn9+StxA9FzXYwUE0t+bbsNJfOOp/kDojf060qLGsu0kAGKd2ca4WiDccR0Cieky335C7Zzhi/Q=,iv:bWQ4wr0CJHSN+6ipUbkYTDWZJyFQjDKszfpVX9EEUsY=,tag:kADIFgJBEGCvr5fPbbdEDA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -0,0 +1 @@
../../../../../../sops/users/admin

View File

@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBcTCCARigAwIBAgIRAIix99+AE7Y+uyiLGaRHEhUwCgYIKoZIzj0EAwIwFzEV
MBMGA1UEAxMMQ2xhbiBSb290IENBMB4XDTI1MDkwMTIwNDI1N1oXDTI2MDkwMjA4
NDI1N1owFzEVMBMGA1UEAxMMQ2xhbiBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZI
zj0DAQcDQgAEk7nn9kzxI+xkRmNMlxD+7T78UqV3aqus0foJh6uu1CHC+XaebMcw
JN95nAe3oYA3yZG6Mnq9nCxsYha4EhzGYqNFMEMwDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFJ+yfPimEwnH2y7+SB4X8VtlAIGD
MAoGCCqGSM49BAMCA0cAMEQCIBId/CcbT5MPFL90xa+XQz+gVTdRwsu6Bg7ehMso
Bj0oAiBjSlttd5yeuZGXBm+O0Gl+WdKV60QlrWutNewXFS4UpQ==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:PnEXteU3I7U0OKgE+oR3xjHdLWYTpJjM/jlzxtGU0uP2pUBuQv3LxtEz+cP0ZsafHLNq2iNJ7xpUEE0g4d3M296S56oSocK3fREWBiJFiaC7SAEUiil1l3UCwHn7LzmdEmn8Kq7T+FK89wwqtVWIASLo2gZC/yHE5eEanEATTchGLSNiHJRzZ8n0Ekm8EFUA6czOqA5nPQHaSmeLzu1g80lSSi1ICly6dJksa6DVucwOyVFYFEeq8Dfyc1eyP8L1ee0D7QFYBMduYOXTKPtNnyDmdaQMj7cMMvE7fn04idIiAqw=,iv:nvLmAfFk2GXnnUy+Afr648R60Ou13eu9UKykkiA8Y+4=,tag:lTTAxfG0EDCU6u7xlW6xSQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEMjNWUm5NbktQeTRWRjJE\nWWFZc2Rsa3I5aitPSno1WnhORENNcng5OHprCjNUQVhBVHFBcWFjaW5UdmxKTnZw\nQlI4MDk5Wkp0RElCeWgzZ2dFQkF2dkkKLS0tIDVreTkydnJ0RDdHSHlQeVV6bGlP\nTmpJOVBSb2dkVS9TZG5SRmFjdnQ1b3cKQ5XvwH1jD4XPVs5RzOotBDq8kiE6S5k2\nDBv6ugjsM5qV7/oGP9H69aSB4jKPZjEn3yiNw++Oorc8uXd5kSGh7w==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-02T08:43:00Z",
"mac": "ENC[AES256_GCM,data:3jFf66UyZUWEtPdPu809LCS3K/Hc6zbnluystl3eXS+KGI+dCoYmN9hQruRNBRxf6jli2RIlArmmEPBDQVt67gG/qugTdT12krWnYAZ78iocmOnkf44fWxn/pqVnn4JYpjEYRgy8ueGDnUkwvpGWVZpcXw5659YeDQuYOJ2mq0U=,iv:3k7fBPrABdLItQ2Z+Mx8Nx0eIEKo93zG/23K+Q5Hl3I=,tag:aehAObdx//DEjbKlOeM7iQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -0,0 +1 @@
../../../../../sops/users/admin

View File

@@ -1,4 +1,5 @@
{ ... }: { ... }:
{ {
_class = "clan.service"; _class = "clan.service";
manifest.name = "coredns"; manifest.name = "coredns";
@@ -25,6 +26,12 @@
# TODO: Set a default # TODO: Set a default
description = "IP for the DNS to listen on"; description = "IP for the DNS to listen on";
}; };
options.dnsPort = lib.mkOption {
type = lib.types.int;
default = 1053;
description = "Port of the clan-internal DNS server";
};
}; };
perInstance = perInstance =
@@ -42,8 +49,8 @@
}: }:
{ {
networking.firewall.allowedTCPPorts = [ 53 ]; networking.firewall.allowedTCPPorts = [ settings.dnsPort ];
networking.firewall.allowedUDPPorts = [ 53 ]; networking.firewall.allowedUDPPorts = [ settings.dnsPort ];
services.coredns = services.coredns =
let let
@@ -74,16 +81,22 @@
in in
{ {
enable = true; enable = true;
config = '' config =
. {
forward . 1.1.1.1
cache 30
}
${settings.tld} { let
file ${zonefile} dnsPort = builtins.toString settings.dnsPort;
} in
'';
''
.:${dnsPort} {
forward . 1.1.1.1
cache 30
}
${settings.tld}:${dnsPort} {
file ${zonefile}
}
'';
}; };
}; };
}; };
@@ -107,10 +120,16 @@
# TODO: Set a default # TODO: Set a default
description = "IP on which the services will listen"; description = "IP on which the services will listen";
}; };
options.dnsPort = lib.mkOption {
type = lib.types.int;
default = 1053;
description = "Port of the clan-internal DNS server";
};
}; };
perInstance = perInstance =
{ roles, ... }: { roles, settings, ... }:
{ {
nixosModule = nixosModule =
{ lib, ... }: { lib, ... }:
@@ -147,7 +166,7 @@
]; ];
stub-zone = map (m: { stub-zone = map (m: {
name = "${roles.server.machines.${m}.settings.tld}."; name = "${roles.server.machines.${m}.settings.tld}.";
stub-addr = "${roles.server.machines.${m}.settings.ip}"; stub-addr = "${roles.server.machines.${m}.settings.ip}@${builtins.toString settings.dnsPort}";
}) (lib.attrNames roles.server.machines); }) (lib.attrNames roles.server.machines);
}; };
}; };

View File

@@ -95,18 +95,15 @@
for m in machines: for m in machines:
m.wait_for_unit("network-online.target") 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 # 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://one.foo")
# client.succeed("curl -k -v http://two.foo") # client.succeed("curl -k -v http://two.foo")
answer = client.succeed("dig @192.168.1.2 one.foo") answer = client.succeed("dig @192.168.1.2 -p 1053 one.foo")
assert "192.168.1.3" in answer, "IP not found" assert "192.168.1.3" in answer, "IP not found"
answer = client.succeed("dig @192.168.1.2 two.foo") answer = client.succeed("dig @192.168.1.2 -p 1053 two.foo")
assert "192.168.1.4" in answer, "IP not found" assert "192.168.1.4" in answer, "IP not found"
''; '';

View File

@@ -56,6 +56,11 @@
systemd.services.telegraf-json = { systemd.services.telegraf-json = {
enable = true; enable = true;
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "telegraf.service" ];
wants = [ "telegraf.service" ];
serviceConfig = {
Restart = "on-failure";
};
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}"; script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
}; };

12
devFlake/flake.lock generated
View File

@@ -84,11 +84,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1756662818, "lastModified": 1757007868,
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=", "narHash": "sha256-zekS8JUSNEiphLnjWJBFoaX4Kb8GxiiD6FvoKZI+8b0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09", "rev": "36420cc41abb467f89082432cfe139f5fdbdcea3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -107,11 +107,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1755555503, "lastModified": 1756738487,
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=", "narHash": "sha256-8QX7Ab5CcICp7zktL47VQVS+QeaU4YDNAjzty7l7TQE=",
"owner": "NuschtOS", "owner": "NuschtOS",
"repo": "search", "repo": "search",
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea", "rev": "5feeaeefb571e6ca2700888b944f436f7c05149b",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -94,6 +94,7 @@ nav:
- reference/clanServices/index.md - reference/clanServices/index.md
- reference/clanServices/admin.md - reference/clanServices/admin.md
- reference/clanServices/borgbackup.md - reference/clanServices/borgbackup.md
- reference/clanServices/certificates.md
- reference/clanServices/coredns.md - reference/clanServices/coredns.md
- reference/clanServices/data-mesher.md - reference/clanServices/data-mesher.md
- reference/clanServices/dyndns.md - reference/clanServices/dyndns.md

View File

@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
# Direct SSH with fallback support # Direct SSH with fallback support
internet = { internet = {
roles.default.machines.server1 = { roles.default.machines.server1 = {
settings.address = "server1.example.com"; settings.host = "server1.example.com";
}; };
roles.default.machines.server2 = { roles.default.machines.server2 = {
settings.address = "192.168.1.100"; settings.host = "192.168.1.100";
}; };
}; };
@@ -50,7 +50,7 @@ For machines with public IPs or DNS names, use the `internet` service to configu
# Priority 1: Try direct connection first # Priority 1: Try direct connection first
internet = { internet = {
roles.default.machines.publicserver = { roles.default.machines.publicserver = {
settings.address = "public.example.com"; settings.host = "public.example.com";
}; };
}; };

18
flake.lock generated
View File

@@ -31,11 +31,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1756115622, "lastModified": 1756733629,
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=", "narHash": "sha256-dwWGlDhcO5SMIvMSTB4mjQ5Pvo2vtxvpIknhVnSz2I8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "bafad29f89e83b2d861b493aa23034ea16595560", "rev": "a5c4f2ab72e3d1ab43e3e65aa421c6f2bd2e12a1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -51,11 +51,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1754487366, "lastModified": 1756770412,
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=", "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18", "rev": "4524271976b625a4a605beefd893f270620fd751",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -71,11 +71,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1755825449, "lastModified": 1757015938,
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=", "narHash": "sha256-1qBXNK/QxEjCqIoA2DxWn5gqM8rVxt+OxKodXu1GLTY=",
"owner": "nix-darwin", "owner": "nix-darwin",
"repo": "nix-darwin", "repo": "nix-darwin",
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8", "rev": "eaacfa1101b84225491d2ceae9549366d74dc214",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -87,6 +87,8 @@ in
relativeDir = removePrefix "${self}/" (toString config.clan.directory); relativeDir = removePrefix "${self}/" (toString config.clan.directory);
update-vars = hostPkgs.writeShellScriptBin "update-vars" '' update-vars = hostPkgs.writeShellScriptBin "update-vars" ''
set -x
export PRJ_ROOT=$(git rev-parse --show-toplevel)
${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName} ${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName}
''; '';

View File

@@ -268,8 +268,14 @@ class Machine:
) )
def nsenter_command(self, command: str) -> list[str]: def nsenter_command(self, command: str) -> list[str]:
nsenter = shutil.which("nsenter")
if not nsenter:
msg = "nsenter command not found"
raise RuntimeError(msg)
return [ return [
"nsenter", nsenter,
"--target", "--target",
str(self.container_pid), str(self.container_pid),
"--mount", "--mount",
@@ -326,6 +332,7 @@ class Machine:
return subprocess.run( return subprocess.run(
self.nsenter_command(command), self.nsenter_command(command),
env={},
timeout=timeout, timeout=timeout,
check=False, check=False,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,

View File

@@ -109,6 +109,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
title="Clan App", title="Clan App",
size=Size(1280, 1024, SizeHint.NONE), size=Size(1280, 1024, SizeHint.NONE),
shared_threads=shared_threads, shared_threads=shared_threads,
app_id="org.clan.app",
) )
API.overwrite_fn(get_system_file) API.overwrite_fn(get_system_file)

View File

@@ -5,6 +5,11 @@ import platform
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
from pathlib import Path from pathlib import Path
# Native handle kinds
WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW = 0
WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET = 1
WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER = 2
def _encode_c_string(s: str) -> bytes: def _encode_c_string(s: str) -> bytes:
return s.encode("utf-8") return s.encode("utf-8")
@@ -72,6 +77,10 @@ class _WebviewLibrary:
self.webview_create.argtypes = [c_int, c_void_p] self.webview_create.argtypes = [c_int, c_void_p]
self.webview_create.restype = c_void_p self.webview_create.restype = c_void_p
self.webview_create_with_app_id = self.lib.webview_create_with_app_id
self.webview_create_with_app_id.argtypes = [c_int, c_void_p, c_char_p]
self.webview_create_with_app_id.restype = c_void_p
self.webview_destroy = self.lib.webview_destroy self.webview_destroy = self.lib.webview_destroy
self.webview_destroy.argtypes = [c_void_p] self.webview_destroy.argtypes = [c_void_p]
@@ -105,6 +114,10 @@ class _WebviewLibrary:
self.webview_return = self.lib.webview_return self.webview_return = self.lib.webview_return
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p] self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
self.webview_get_native_handle = self.lib.webview_get_native_handle
self.webview_get_native_handle.argtypes = [c_void_p, c_int]
self.webview_get_native_handle.restype = c_void_p
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p) self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
self.CFUNCTYPE = CFUNCTYPE self.CFUNCTYPE = CFUNCTYPE

View File

@@ -1,6 +1,7 @@
import functools import functools
import json import json
import logging import logging
import platform
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -11,7 +12,10 @@ from typing import TYPE_CHECKING, Any
from clan_lib.api import MethodRegistry, message_queue from clan_lib.api import MethodRegistry, message_queue
from clan_lib.api.tasks import WebThread from clan_lib.api.tasks import WebThread
from ._webview_ffi import _encode_c_string, _webview_lib from ._webview_ffi import (
_encode_c_string,
_webview_lib,
)
from .webview_bridge import WebviewBridge from .webview_bridge import WebviewBridge
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -32,6 +36,21 @@ class FuncStatus(IntEnum):
FAILURE = 1 FAILURE = 1
class NativeHandleKind(IntEnum):
# Top-level window. @c GtkWindow pointer (GTK), @c NSWindow pointer (Cocoa)
# or @c HWND (Win32)
UI_WINDOW = 0
# Browser widget. @c GtkWidget pointer (GTK), @c NSView pointer (Cocoa) or
# @c HWND (Win32).
UI_WIDGET = 1
# Browser controller. @c WebKitWebView pointer (WebKitGTK), @c WKWebView
# pointer (Cocoa/WebKit) or @c ICoreWebView2Controller pointer
# (Win32/WebView2).
BROWSER_CONTROLLER = 2
@dataclass(frozen=True) @dataclass(frozen=True)
class Size: class Size:
width: int width: int
@@ -46,6 +65,7 @@ class Webview:
size: Size | None = None size: Size | None = None
window: int | None = None window: int | None = None
shared_threads: dict[str, WebThread] | None = None shared_threads: dict[str, WebThread] | None = None
app_id: str | None = None
# initialized later # initialized later
_bridge: WebviewBridge | None = None _bridge: WebviewBridge | None = None
@@ -56,7 +76,14 @@ class Webview:
def _create_handle(self) -> None: def _create_handle(self) -> None:
# Initialize the webview handle # Initialize the webview handle
with_debugger = True with_debugger = True
handle = _webview_lib.webview_create(int(with_debugger), self.window)
# Use webview_create_with_app_id only on Linux if app_id is provided
if self.app_id and platform.system() == "Linux":
handle = _webview_lib.webview_create_with_app_id(
int(with_debugger), self.window, _encode_c_string(self.app_id)
)
else:
handle = _webview_lib.webview_create(int(with_debugger), self.window)
callbacks: dict[str, Callable[..., Any]] = {} callbacks: dict[str, Callable[..., Any]] = {}
# Since we can't use object.__setattr__, we'll initialize differently # Since we can't use object.__setattr__, we'll initialize differently
@@ -217,6 +244,21 @@ class Webview:
self._callbacks[name] = c_callback self._callbacks[name] = c_callback
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None) _webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
def get_native_handle(
self, kind: NativeHandleKind = NativeHandleKind.UI_WINDOW
) -> int | None:
"""Get the native handle (platform-dependent).
Args:
kind: Handle kind - NativeHandleKind enum value
Returns:
Native handle as integer, or None if failed
"""
handle = _webview_lib.webview_get_native_handle(self.handle, kind.value)
return handle if handle else None
def unbind(self, name: str) -> None: def unbind(self, name: str) -> None:
if name in self._callbacks: if name in self._callbacks:
_webview_lib.webview_unbind(self.handle, _encode_c_string(name)) _webview_lib.webview_unbind(self.handle, _encode_c_string(name))

View File

@@ -11,6 +11,11 @@
gobject-introspection, gobject-introspection,
gtk4, gtk4,
lib, lib,
stdenv,
# macOS-specific dependencies
imagemagick,
makeWrapper,
libicns,
}: }:
let let
source = source =
@@ -91,7 +96,12 @@ pythonRuntime.pkgs.buildPythonApplication {
# gtk4 deps # gtk4 deps
wrapGAppsHook4 wrapGAppsHook4
] ]
++ runtimeDependencies; ++ runtimeDependencies
++ lib.optionals stdenv.hostPlatform.isDarwin [
imagemagick
makeWrapper
libicns
];
# The necessity of setting buildInputs and propagatedBuildInputs to the # The necessity of setting buildInputs and propagatedBuildInputs to the
# same values for your Python package within Nix largely stems from ensuring # same values for your Python package within Nix largely stems from ensuring
@@ -148,16 +158,113 @@ pythonRuntime.pkgs.buildPythonApplication {
postInstall = '' postInstall = ''
mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui
cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui
mkdir -p $out/share/icons/hicolor
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor ${lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
mkdir -p $out/share/icons/hicolor
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
''}
${lib.optionalString stdenv.hostPlatform.isDarwin ''
set -eu pipefail
# Create macOS app bundle structure
mkdir -p "$out/Applications/Clan App.app/Contents/"{MacOS,Resources}
# Create Info.plist
cat > "$out/Applications/Clan App.app/Contents/Info.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Clan App</string>
<key>CFBundleExecutable</key>
<string>Clan App</string>
<key>CFBundleIconFile</key>
<string>clan-app.icns</string>
<key>CFBundleIdentifier</key>
<string>org.clan.app</string>
<key>CFBundleName</key>
<string>Clan App</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Clan Protocol</string>
<key>CFBundleURLSchemes</key>
<array>
<string>clan</string>
</array>
</dict>
</array>
</dict>
</plist>
EOF
# Create app icon (convert PNG to ICNS using minimal approach to avoid duplicates)
# Create a temporary iconset directory structure
mkdir clan-app.iconset
# Create a minimal iconset with only essential, non-duplicate sizes
# Each PNG file should map to a unique ICNS type
cp ./clan_app/assets/white-favicons/16x16/apps/clan-app.png clan-app.iconset/icon_16x16.png
cp ./clan_app/assets/white-favicons/128x128/apps/clan-app.png clan-app.iconset/icon_128x128.png
# Use libicns png2icns tool to create proper ICNS file with minimal set
png2icns "$out/Applications/Clan App.app/Contents/Resources/clan-app.icns" \
clan-app.iconset/icon_16x16.png \
clan-app.iconset/icon_128x128.png
# Create PkgInfo file (standard requirement for macOS apps)
echo -n "APPL????" > "$out/Applications/Clan App.app/Contents/PkgInfo"
# Create the main executable script with proper process name
cat > "$out/Applications/Clan App.app/Contents/MacOS/Clan App" << EOF
#!/bin/bash
# Execute with the correct process name for app icon to appear
exec -a "\$0" "$out/bin/.clan-app-orig" "\$@"
EOF
chmod +x "$out/Applications/Clan App.app/Contents/MacOS/Clan App"
set +eu pipefail
''}
''; '';
# TODO: If we start clan-app over the cli the process name is "python" and icons don't show up correctly on macOS
# I looked in how blender does it, but couldn't figure it out yet.
# They do an exec -a in their wrapper script, but that doesn't seem to work here.
# Don't leak python packages into a devshell. # Don't leak python packages into a devshell.
# It can be very confusing if you `nix run` than load the cli from the devshell instead. # It can be very confusing if you `nix run` than load the cli from the devshell instead.
postFixup = '' postFixup = ''
rm $out/nix-support/propagated-build-inputs rm $out/nix-support/propagated-build-inputs
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
set -eu pipefail
mv $out/bin/clan-app $out/bin/.clan-app-orig
# Create command line wrapper that executes the app bundle
cat > $out/bin/clan-app << EOF
#!/bin/bash
exec "$out/Applications/Clan App.app/Contents/MacOS/Clan App" "\$@"
EOF
chmod +x $out/bin/clan-app
set +eu pipefail
''; '';
checkPhase = '' checkPhase = ''
set -eu pipefail
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
@@ -171,6 +278,7 @@ pythonRuntime.pkgs.buildPythonApplication {
fc-list fc-list
PYTHONPATH= $out/bin/clan-app --help PYTHONPATH= $out/bin/clan-app --help
set +eu pipefail
''; '';
desktopItems = [ desktop-file ]; desktopItems = [ desktop-file ];
} }

View File

@@ -48,9 +48,9 @@ let
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2"; url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk="; hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
}; };
commitMono_ttf = fetchurl { archivoSemi_ttf = fetchurl {
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf"; url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf";
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08="; hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w=";
}; };
in in
@@ -66,5 +66,5 @@ runCommand "" { } ''
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2 cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
cp ${commitMono} $out/CommitMonoV143-VF.woff2 cp ${commitMono} $out/CommitMonoV143-VF.woff2
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf cp ${archivoSemi_ttf} $out/ArchivoSemiCondensed-Medium.ttf
'' ''

View File

@@ -1,10 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
if ! command -v xdg-mime &> /dev/null; then
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
fi
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))') ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))')
if [ "$ALREADY_INSTALLED" = "true" ]; then if [ "$ALREADY_INSTALLED" = "true" ]; then
@@ -14,9 +9,23 @@ else
nix profile install .#clan-app nix profile install .#clan-app
fi fi
# Check OS type
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
# install desktop file if ! command -v xdg-mime &> /dev/null; then
set -eou pipefail echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
DESKTOP_FILE_NAME=org.clan.app.desktop fi
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan # install desktop file on Linux
set -eou pipefail
DESKTOP_FILE_NAME=org.clan.app.desktop
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macOS detected."
mkdir -p ~/Applications
ln -sf ~/.nix-profile/Applications/Clan\ App.app ~/Applications
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f ~/Applications/Clan\ App.app
else
echo "Unsupported OS: $OSTYPE"
fi

9
pkgs/clan-app/macos-remote.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -eou pipefail
rsync --exclude result --exclude .direnv --exclude node_modules --delete -r ~/Projects/clan-core/pkgs/clan-app mac-mini-dev:~/clan-core/pkgs
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && nix build .#clan-app -Lv --show-trace"
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && ./install-desktop.sh"

View File

@@ -91,6 +91,8 @@ mkShell {
pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui" pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui"
export NODE_PATH="$(pwd)/node_modules" export NODE_PATH="$(pwd)/node_modules"
export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH" export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH"
rm -rf .fonts || true
cp -r ${self'.packages.fonts} .fonts cp -r ${self'.packages.fonts} .fonts
chmod -R +w .fonts chmod -R +w .fonts
mkdir -p api mkdir -p api

View File

@@ -37,6 +37,11 @@ export const Menu = (props: {
"pointer-events": "auto", "pointer-events": "auto",
}} }}
class={styles.list} class={styles.list}
onContextMenu={(e) => {
// Prevent default context menu
e.preventDefault();
e.stopPropagation();
}}
> >
<li <li
class={styles.item} class={styles.item}

View File

@@ -1,4 +1,4 @@
fieldset { .fieldset {
@apply flex flex-col w-full; @apply flex flex-col w-full;
legend { legend {

View File

@@ -35,10 +35,10 @@ export const Fieldset = (props: FieldsetProps) => {
: props.children; : props.children;
return ( return (
<fieldset <div
role="group" role="group"
class={cx({ inverted: props.inverted })} class={cx("fieldset", { inverted: props.inverted })}
disabled={props.disabled || false} aria-disabled={props.disabled || undefined}
> >
{props.legend && ( {props.legend && (
<legend> <legend>
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography> </Typography>
</div> </div>
)} )}
</fieldset> </div>
); );
}; };

View File

@@ -1,221 +0,0 @@
div.form-field.machine-tags {
div.control {
@apply flex flex-col size-full gap-2;
div.selected-options {
@apply flex flex-wrap gap-2 size-full min-h-5;
}
div.input-container {
@apply relative left-0 top-0;
@apply inline-flex justify-between w-full;
input {
@apply w-full px-2 py-1.5 rounded-sm;
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
font-size: 0.875rem;
font-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 1;
&::placeholder {
@apply fg-def-4;
}
&:hover {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:not(:read-only):focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
0 0 0 0.125rem theme(colors.bg.def.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-disabled] {
@apply outline-def-2 fg-def-4 cursor-not-allowed;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit;
@apply p-0 resize-none;
}
}
& > button.trigger {
@apply flex items-center justify-center w-8;
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
&[data-disabled] {
@apply cursor-not-allowed;
}
& > span.icon {
@apply h-full w-full py-0.5 px-1;
}
}
}
}
&.horizontal {
@apply flex-row gap-2 justify-between;
div.control {
@apply w-1/2 grow;
}
}
&.s {
div.control > div.input-container {
& > input {
@apply px-1.5 py-1;
font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
}
& > button.trigger {
@apply top-[0.1875rem] h-4 w-5;
}
}
}
&.inverted {
div.control > div.input-container {
& > button.trigger {
@apply bg-inv-2;
}
& > input {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder {
@apply fg-inv-4;
}
&:hover {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:not(:read-only):focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
}
}
}
&.ghost {
div.control > div.input-container {
& > input {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}
}
div.machine-tags-content {
@apply rounded-sm bg-def-1 border border-def-2 z-10;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: machineTagsContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: machineTagsContentShow 250ms ease-out;
}
& > ul.listbox {
overflow-y: auto;
max-height: 360px;
@apply px-2 py-3;
&:focus {
outline: none;
}
li.item {
@apply flex items-center justify-between;
@apply relative px-2 py-1;
@apply select-none outline-none rounded-[0.25rem];
color: hsl(240 4% 16%);
height: 32px;
&[data-disabled] {
color: hsl(240 5% 65%);
opacity: 0.5;
pointer-events: none;
}
&[data-highlighted] {
@apply outline-none bg-def-4;
}
}
.item-indicator {
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
div.machine-tags-control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
@apply flex gap-2 flex-wrap w-full;
}
& > div.input-container {
@apply w-full flex gap-2;
}
}
@keyframes machineTagsContentShow {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes machineTagsContentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,207 @@
.machineTags {
&.horizontal {
@apply flex-row gap-2 justify-between;
}
}
.control {
@apply flex flex-col size-full gap-2;
&.horizontal {
@apply w-1/2 grow;
}
}
.selectedOptions {
@apply flex flex-wrap gap-2 size-full min-h-5;
}
.trigger {
@apply w-full relative;
}
.icon {
@apply absolute left-1.5;
top: calc(50% - 0.5rem);
&.iconSmall {
@apply left-[0.3125rem] size-[0.75rem];
top: calc(50% - 0.3125rem);
}
}
.input {
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1 w-full;
@apply px-[1.625rem] py-1.5 rounded-sm;
font-size: 0.875rem;
font-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 1;
&::placeholder {
@apply fg-def-4;
}
&:hover {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:not(:read-only):focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
0 0 0 0.125rem theme(colors.bg.def.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-disabled] {
@apply outline-def-2 fg-def-4 cursor-not-allowed;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit;
@apply p-0 resize-none;
}
&.inputSmall {
@apply px-[1.25rem] py-1;
font-size: 0.8125rem;
&[data-readonly] {
@apply p-0;
}
}
&.inputInverted {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder {
@apply fg-inv-4;
}
&:hover {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:not(:read-only):focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
}
&.inputGhost {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
.comboboxContent {
@apply rounded-sm bg-def-1 border border-def-2 z-20;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: machineTagsContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: machineTagsContentShow 250ms ease-out;
}
.listbox {
overflow-y: auto;
max-height: 360px;
@apply px-2 py-3;
&:focus {
outline: none;
}
.listboxItem {
@apply flex items-center justify-between;
@apply relative px-2 py-1;
@apply select-none outline-none rounded-[0.25rem];
color: hsl(240 4% 16%);
height: 32px;
&[data-disabled] {
color: hsl(240 5% 65%);
opacity: 0.5;
pointer-events: none;
}
&[data-highlighted] {
@apply outline-none bg-def-4;
}
&.listboxItemInverted {
&[data-highlighted] {
@apply bg-inv-4;
}
}
}
.itemIndicator {
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
&.comboboxContentInverted {
@apply bg-inv-1 border-inv-2;
}
}
.machineTagsControl {
@apply flex flex-col w-full gap-2;
/*& > div.selected-options {*/
/* @apply flex gap-2 flex-wrap w-full;*/
/*}*/
& > div.input-container {
@apply w-full flex gap-2;
}
}
@keyframes machineTagsContentShow {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes machineTagsContentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { MachineTags, MachineTagsProps } from "./MachineTags";
import { createForm, setValue } from "@modular-forms/solid";
import { Button } from "../Button/Button";
const meta = {
title: "Components/MachineTags",
component: MachineTags,
} satisfies Meta<MachineTagsProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [formStore, { Field, Form }] = createForm<{ tags: string[] }>({
initialValues: { tags: ["nixos"] },
});
const handleSubmit = (values: { tags: string[] }) => {
console.log("submitting", values);
};
const readonly = ["nixos"];
const options = ["foo"];
return (
<Form onSubmit={handleSubmit}>
<Field name="tags" type="string[]">
{(field, props) => (
<MachineTags
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
name="Tags"
defaultOptions={options}
readonlyOptions={readonly}
readOnly={false}
defaultValue={field.value}
/>
)}
</Field>
<Button type="submit" hierarchy="primary">
Submit
</Button>
</Form>
);
},
};

View File

@@ -1,29 +1,35 @@
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js"; import {
createEffect,
on,
createSignal,
For,
Show,
splitProps,
} from "solid-js";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import cx from "classnames"; import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { Tag } from "@/src/components/Tag/Tag"; import { Tag } from "@/src/components/Tag/Tag";
import "./MachineTags.css";
import { Label } from "@/src/components/Form/Label"; import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter"; import { Orienter } from "@/src/components/Form/Orienter";
import { CollectionNode } from "@kobalte/core"; import { CollectionNode } from "@kobalte/core";
import styles from "./MachineTags.module.css";
export interface MachineTag { export interface MachineTag {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
new?: boolean;
} }
export type MachineTagsProps = FieldProps & { export type MachineTagsProps = FieldProps & {
name: string; name: string;
input: ComponentProps<"select">; onChange: (values: string[]) => void;
defaultValue?: string[];
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
defaultValue?: string[];
defaultOptions?: string[]; defaultOptions?: string[];
readonlyOptions?: string[]; readonlyOptions?: string[];
}; };
@@ -44,26 +50,12 @@ const sortedOptions = (options: MachineTag[]) =>
const sortedAndUniqueOptions = (options: MachineTag[]) => const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options)); sortedOptions(uniqueOptions(options));
// customises how each option is displayed in the dropdown
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item item={props.item} class="item">
<Combobox.ItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
export const MachineTags = (props: MachineTagsProps) => { export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[] const [local, rest] = splitProps(props, ["defaultValue"]);
// // convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions( const defaultValue = sortedAndUniqueOptions(
(props.defaultValue || []).map((value) => ({ value })), (local.defaultValue || []).map((value) => ({ value })),
); );
// convert default options string[] into MachineTag[] // convert default options string[] into MachineTag[]
@@ -77,6 +69,51 @@ export const MachineTags = (props: MachineTagsProps) => {
]), ]),
); );
const [selectedOptions, setSelectedOptions] =
createSignal<MachineTag[]>(defaultValue);
const handleToggle = (item: CollectionNode<MachineTag>) => () => {
setSelectedOptions((current) => {
const exists = current.find(
(option) => option.value === item.rawValue.value,
);
if (exists) {
return current.filter((option) => option.value !== item.rawValue.value);
}
return [...current, item.rawValue];
});
};
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
onClick={handleToggle(props.item)}
>
<Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
inverted={inverted}
>
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class={styles.itemIndicator}>
<Icon icon="Checkmark" inverted={inverted} />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
let selectRef: HTMLSelectElement;
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input // react when enter is pressed inside of the text input
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -85,22 +122,49 @@ export const MachineTags = (props: MachineTagsProps) => {
// get the current input value, exiting early if it's empty // get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement; const input = event.currentTarget as HTMLInputElement;
if (input.value === "") return; const trimmed = input.value.trim();
if (!trimmed) return;
setAvailableOptions((options) => { setAvailableOptions((curr) => {
return options.map((option) => { if (curr.find((option) => option.value === trimmed)) {
return { return curr;
...option, }
new: undefined, return [
}; ...curr,
}); {
value: trimmed,
},
];
});
setSelectedOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
return [
...curr,
{
value: trimmed,
},
];
}); });
// reset the input value selectRef.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
selectRef.dispatchEvent(
new Event("change", { bubbles: true, cancelable: true }),
);
input.value = ""; input.value = "";
} }
}; };
// Notify when selected options change
createEffect(
on(selectedOptions, (options) => {
props.onChange(options.map((o) => o.value));
}),
);
const align = () => { const align = () => {
if (props.readOnly) { if (props.readOnly) {
return "center"; return "center";
@@ -112,41 +176,19 @@ export const MachineTags = (props: MachineTagsProps) => {
return ( return (
<Combobox<MachineTag> <Combobox<MachineTag>
multiple multiple
class={cx("form-field", "machine-tags", props.size, props.orientation, { class={cx("form-field", styles.machineTags, props.orientation)}
inverted: props.inverted,
ghost: props.ghost,
})}
{...splitProps(props, ["defaultValue"])[1]} {...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue} defaultValue={defaultValue}
value={selectedOptions()}
options={availableOptions()} options={availableOptions()}
optionValue="value" optionValue="value"
optionTextValue="value" optionTextValue="value"
optionLabel="value" optionLabel="value"
optionDisabled="disabled" optionDisabled="disabled"
itemComponent={ItemComponent} itemComponent={ItemComponent(props.inverted || false)}
placeholder="Enter a tag name" placeholder="Start typing a name and press enter"
// triggerMode="focus" onChange={() => {
removeOnBackspace={false} // noop, we handle this via the selectedOptions signal
defaultFilter={() => true}
onInput={(event) => {
const input = event.target as HTMLInputElement;
// as the user types in the input box, we maintain a "new" option
// in the list of available options
setAvailableOptions((options) => {
return [
// remove the old "new" entry
...options.filter((option) => !option.new),
// add the updated "new" entry
{ value: input.value, new: true },
];
});
}}
onBlur={() => {
// clear the in-progress "new" option from the list of available options
setAvailableOptions((options) => {
return options.filter((option) => !option.new);
});
}} }}
> >
<Orienter orientation={props.orientation} align={align()}> <Orienter orientation={props.orientation} align={align()}>
@@ -156,11 +198,18 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props} {...props}
/> />
<Combobox.HiddenSelect {...props.input} multiple /> <Combobox.HiddenSelect
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.Control<MachineTag> class="control"> <Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)}
>
{(state) => ( {(state) => (
<div class="selected-options"> <div class={styles.selectedOptions}>
<For each={state.selectedOptions()}> <For each={state.selectedOptions()}>
{(option) => ( {(option) => (
<Tag <Tag
@@ -177,7 +226,13 @@ export const MachineTags = (props: MachineTagsProps) => {
icon={"Close"} icon={"Close"}
size="0.5rem" size="0.5rem"
inverted={inverted} inverted={inverted}
onClick={() => state.remove(option)} onClick={() =>
setSelectedOptions((curr) => {
return curr.filter(
(o) => o.value !== option.value,
);
})
}
/> />
) )
} }
@@ -187,27 +242,36 @@ export const MachineTags = (props: MachineTagsProps) => {
)} )}
</For> </For>
<Show when={!props.readOnly}> <Show when={!props.readOnly}>
<div class="input-container"> <Combobox.Trigger class={styles.trigger}>
<Combobox.Input onKeyDown={onKeyDown} /> <Icon
<Combobox.Trigger class="trigger"> icon="Tag"
<Combobox.Icon class="icon"> color="secondary"
<Icon inverted={props.inverted}
icon="Expand" class={cx(styles.icon, {
inverted={!props.inverted} [styles.iconSmall]: props.size == "s",
size="100%" })}
/> />
</Combobox.Icon> <Combobox.Input
</Combobox.Trigger> onKeyDown={onKeyDown}
</div> class={cx(styles.input, {
[styles.inputSmall]: props.size == "s",
[styles.inputGhost]: props.ghost,
[styles.inputInverted]: props.inverted,
})}
/>
</Combobox.Trigger>
</Show> </Show>
</div> </div>
)} )}
</Combobox.Control> </Combobox.Control>
</Orienter> </Orienter>
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content class="machine-tags-content"> <Combobox.Content
<Combobox.Listbox class="listbox" /> class={cx(styles.comboboxContent, {
[styles.comboboxContentInverted]: props.inverted,
})}
>
<Combobox.Listbox class={styles.listbox} />
</Combobox.Content> </Combobox.Content>
</Combobox.Portal> </Combobox.Portal>
</Combobox> </Combobox>

View File

@@ -76,6 +76,19 @@ div.form-field {
@apply absolute left-2 top-1/2 transform -translate-y-1/2; @apply absolute left-2 top-1/2 transform -translate-y-1/2;
@apply w-[0.875rem] h-[0.875rem] pointer-events-none; @apply w-[0.875rem] h-[0.875rem] pointer-events-none;
} }
& > .start-component {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
}
& > .end-component {
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
}
& > .start-component,
& > .end-component {
@apply size-fit;
}
} }
&.s { &.s {
@@ -101,7 +114,7 @@ div.form-field {
} }
& > .icon { & > .icon {
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2; @apply w-[0.6875rem] h-[0.6875rem];
} }
} }
} }

View File

@@ -1,6 +1,8 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames"; import cx from "classnames";
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput"; import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
import Icon from "../Icon/Icon";
import { Button } from "@kobalte/core/button";
const Examples = (props: TextInputProps) => ( const Examples = (props: TextInputProps) => (
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
@@ -83,16 +85,38 @@ export const Tooltip: Story = {
}, },
}; };
export const Icon: Story = { export const WithIcon: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
icon: "Checkmark", startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />,
},
};
export const WithStartComponent: Story = {
args: {
...Tooltip.args,
startComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeClose" color="quaternary" {...props} />
</Button>
),
},
};
export const WithEndComponent: Story = {
args: {
...Tooltip.args,
endComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeOpen" color="quaternary" {...props} />
</Button>
),
}, },
}; };
export const Ghost: Story = { export const Ghost: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
ghost: true, ghost: true,
}, },
}; };
@@ -106,14 +130,14 @@ export const Invalid: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
disabled: true, disabled: true,
}, },
}; };
export const ReadOnly: Story = { export const ReadOnly: Story = {
args: { args: {
...Icon.args, ...WithIcon.args,
readOnly: true, readOnly: true,
defaultValue: "14/05/02", defaultValue: "14/05/02",
}, },

View File

@@ -11,12 +11,20 @@ import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { splitProps } from "solid-js"; import {
Component,
createEffect,
createSignal,
onMount,
splitProps,
} from "solid-js";
export type TextInputProps = FieldProps & export type TextInputProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
icon?: IconVariant; icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>; input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
startComponent?: Component<Pick<FieldProps, "inverted">>;
endComponent?: Component<Pick<FieldProps, "inverted">>;
}; };
export const TextInput = (props: TextInputProps) => { export const TextInput = (props: TextInputProps) => {
@@ -28,6 +36,39 @@ export const TextInput = (props: TextInputProps) => {
"ghost", "ghost",
]); ]);
let inputRef: HTMLInputElement | undefined;
let startComponentRef: HTMLDivElement | undefined;
let endComponentRef: HTMLDivElement | undefined;
const [startComponentSize, setStartComponentSize] = createSignal({
width: 0,
height: 0,
});
const [endComponentSize, setEndComponentSize] = createSignal({
width: 0,
height: 0,
});
onMount(() => {
if (startComponentRef) {
const rect = startComponentRef.getBoundingClientRect();
setStartComponentSize({ width: rect.width, height: rect.height });
}
if (endComponentRef) {
const rect = endComponentRef.getBoundingClientRect();
setEndComponentSize({ width: rect.width, height: rect.height });
}
});
createEffect(() => {
if (inputRef) {
const padding = props.size == "s" ? 6 : 8;
inputRef.style.paddingLeft = `${startComponentSize().width + padding * 2}px`;
inputRef.style.paddingRight = `${endComponentSize().width + padding * 2}px`;
}
});
return ( return (
<TextField <TextField
class={cx( class={cx(
@@ -50,6 +91,11 @@ export const TextInput = (props: TextInputProps) => {
{...props} {...props}
/> />
<div class="input-container"> <div class="input-container">
{props.startComponent && !props.readOnly && (
<div ref={startComponentRef} class="start-component">
{props.startComponent({ inverted: props.inverted })}
</div>
)}
{props.icon && !props.readOnly && ( {props.icon && !props.readOnly && (
<Icon <Icon
icon={props.icon} icon={props.icon}
@@ -58,9 +104,17 @@ export const TextInput = (props: TextInputProps) => {
/> />
)} )}
<TextField.Input <TextField.Input
ref={inputRef}
{...props.input} {...props.input}
classList={{ "has-icon": props.icon && !props.readOnly }} class={cx({
"has-icon": props.icon && !props.readOnly,
})}
/> />
{props.endComponent && !props.readOnly && (
<div ref={endComponentRef} class="end-component">
{props.endComponent({ inverted: props.inverted })}
</div>
)}
</div> </div>
</Orienter> </Orienter>
</TextField> </TextField>

View File

@@ -1,6 +1,6 @@
.loader { .loader {
@apply relative; @apply relative;
@apply w-4 h-4; @apply size-full;
&.primary { &.primary {
& > div.wrapper > div.parent, & > div.wrapper > div.parent,
@@ -15,6 +15,18 @@
background: #0051ff; background: #0051ff;
} }
} }
&.sizeDefault {
@apply size-4;
}
&.sizeLarge {
@apply size-8;
}
&.sizeExtraLarge {
@apply size-12;
}
} }
.wrapper { .wrapper {

View File

@@ -1,9 +1,17 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Loader, LoaderProps } from "@/src/components/Loader/Loader"; import { Loader, LoaderProps } from "@/src/components/Loader/Loader";
const LoaderExamples = (props: LoaderProps) => (
<div class="grid grid-cols-8">
<Loader {...props} size="default" />
<Loader {...props} size="l" />
<Loader {...props} size="xl" />
</div>
);
const meta: Meta<LoaderProps> = { const meta: Meta<LoaderProps> = {
title: "Components/Loader", title: "Components/Loader",
component: Loader, component: LoaderExamples,
}; };
export default meta; export default meta;

View File

@@ -7,15 +7,23 @@ export type Hierarchy = "primary" | "secondary";
export interface LoaderProps { export interface LoaderProps {
hierarchy?: Hierarchy; hierarchy?: Hierarchy;
class?: string; class?: string;
size?: "default" | "l" | "xl";
} }
export const Loader = (props: LoaderProps) => { export const Loader = (props: LoaderProps) => {
const size = () => props.size || "default";
return ( return (
<div <div
class={cx( class={cx(
styles.loader, styles.loader,
styles[props.hierarchy || "primary"], styles[props.hierarchy || "primary"],
props.class, props.class,
{
[styles.sizeDefault]: size() === "default",
[styles.sizeLarge]: size() === "l",
[styles.sizeExtraLarge]: size() === "xl",
},
)} )}
> >
<div class={styles.wrapper}> <div class={styles.wrapper}>

View File

@@ -20,6 +20,9 @@ export const MachineStatus = (props: MachineStatusProps) => {
// we will use css transform in the typography component to capitalize // we will use css transform in the typography component to capitalize
const statusText = () => props.status?.replaceAll("_", " "); const statusText = () => props.status?.replaceAll("_", " ");
// our implementation of machine status in the backend needs more time to bake, so for now we only display if a
// machine is not installed
return ( return (
<Switch> <Switch>
<Match when={!status()}> <Match when={!status()}>
@@ -28,9 +31,6 @@ export const MachineStatus = (props: MachineStatusProps) => {
<Match when={status()}> <Match when={status()}>
<Badge <Badge
class={cx("machine-status", { class={cx("machine-status", {
online: status() == "online",
offline: status() == "offline",
"out-of-sync": status() == "out_of_sync",
"not-installed": status() == "not_installed", "not-installed": status() == "not_installed",
})} })}
textValue={status()} textValue={status()}

View File

@@ -1,3 +1,3 @@
.sidebar { .sidebar {
@apply w-60 border-none z-10 h-full flex flex-col; @apply w-60 border-none z-10 h-full flex flex-col rounded-b-md overflow-hidden;
} }

View File

@@ -1,14 +1,15 @@
div.sidebar-body { div.sidebar-body {
@apply py-4 px-2 h-full; @apply py-4 px-2;
/* full - (y padding) */
height: calc(100% - 2rem);
@apply border border-inv-3 rounded-bl-md rounded-br-md; @apply border border-inv-3 rounded-bl-md rounded-br-md;
/* TODO: This is weird, we shouldn't disable native browser features, a11y impacts incomming */
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
overflow-y: auto;
scrollbar-width: none;
background: background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%), linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
linear-gradient( linear-gradient(
@@ -20,13 +21,14 @@ div.sidebar-body {
@apply backdrop-blur-sm; @apply backdrop-blur-sm;
.accordion { .accordion {
@apply w-full mb-4; @apply w-full mb-4 h-full flex flex-col justify-start gap-4;
&:last-child { &:last-child {
@apply mb-0; @apply mb-0;
} }
& > .item { & > .item {
max-height: 50%;
&:last-child { &:last-child {
@apply mb-0; @apply mb-0;
} }
@@ -58,9 +60,13 @@ div.sidebar-body {
} }
& > .content { & > .content {
@apply overflow-hidden flex flex-col; @apply flex flex-col;
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4; @apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
max-height: calc(100% - 24px);
overflow-y: auto;
scrollbar-width: none;
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1); animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
&[data-expanded] { &[data-expanded] {

View File

@@ -3,13 +3,14 @@ import { A } from "@solidjs/router";
import { Accordion } from "@kobalte/core/accordion"; import { Accordion } from "@kobalte/core/accordion";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { For, Show, useContext } from "solid-js"; import { For, Show } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan"; import { buildMachinePath, buildServicePath } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar"; import { SidebarProps } from "./Sidebar";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import { useClanContext } from "@/src/routes/Clan/Clan"; import { useClanContext } from "@/src/routes/Clan/Clan";
import { Instance } from "@/src/workflows/Service/models";
interface MachineProps { interface MachineProps {
clanURI: string; clanURI: string;
@@ -33,19 +34,19 @@ const MachineRoute = (props: MachineProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted={true} inverted
> >
{props.name} {props.name}
</Typography> </Typography>
<MachineStatus status={status()} /> <MachineStatus status={status()} />
</div> </div>
<div class="flex w-full flex-row items-center gap-1"> <div class="flex w-full flex-row items-center gap-1">
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" /> <Icon icon="Flash" size="0.75rem" inverted color="tertiary" />
<Typography <Typography
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="s" size="s"
inverted={true} inverted
color="primary" color="primary"
> >
{props.serviceCount} {props.serviceCount}
@@ -56,18 +57,13 @@ const MachineRoute = (props: MachineProps) => {
); );
}; };
export const SidebarBody = (props: SidebarProps) => { const Machines = () => {
const clanURI = useClanURI();
const ctx = useClanContext(); const ctx = useClanContext();
if (!ctx) {
throw new Error("ClanContext not found");
}
const sectionLabels = (props.staticSections || []).map( const clanURI = ctx.clanURI;
(section) => section.title,
);
// controls which sections are open by default
// we want them all to be open by default
const defaultAccordionValues = ["your-machines", ...sectionLabels];
const machines = () => { const machines = () => {
if (!ctx.machinesQuery.isSuccess) { if (!ctx.machinesQuery.isSuccess) {
@@ -78,6 +74,173 @@ export const SidebarBody = (props: SidebarProps) => {
return Object.keys(result).length > 0 ? result : undefined; return Object.keys(result).length > 0 ? result : undefined;
}; };
return (
<Accordion.Item class="item" value="machines">
<Accordion.Header class="header">
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted
color="tertiary"
>
Your Machines
</Typography>
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<Show
when={machines()}
fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography hierarchy="body" size="s" weight="medium" inverted>
No machines yet
</Typography>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.data.name || id}
serviceCount={machine?.instance_refs?.length ?? 0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content>
</Accordion.Item>
);
};
export const ServiceRoute = (props: {
clanURI: string;
label: string;
id: string;
instance: Instance;
}) => (
<A
href={buildServicePath({
clanURI: props.clanURI,
id: props.id,
module: props.instance.module,
})}
replace={true}
>
<div class="flex w-full flex-col gap-2">
<div class="flex flex-row items-center justify-between">
<Typography
hierarchy="label"
size="xs"
weight="bold"
color="primary"
inverted
>
{props.label}
</Typography>
<Icon icon="Code" size="0.75rem" inverted color="tertiary" />
</div>
{/* Same subtitle as Machine */}
{/* <div class="flex w-full flex-row items-center gap-1">
<Icon icon="Code" size="0.75rem" inverted color="tertiary" />
<Typography
hierarchy="label"
family="mono"
size="s"
inverted
color="primary"
>
{props.instance.resolved.usage_ref.name}
</Typography>
</div> */}
</div>
</A>
);
const Services = () => {
const ctx = useClanContext();
if (!ctx) {
throw new Error("ClanContext not found");
}
const serviceInstances = () => {
if (!ctx.serviceInstancesQuery.isSuccess) {
return [];
}
return Object.entries(ctx.serviceInstancesQuery.data)
.map(([id, instance]) => {
const moduleName = instance.module.name;
const label = moduleName == id ? moduleName : `${moduleName} (${id})`;
return {
id,
label,
instance: instance,
};
})
.sort((a, b) => a.label.localeCompare(b.label));
};
return (
<Accordion.Item class="item" value="services">
<Accordion.Header class="header">
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted
color="tertiary"
>
Services
</Typography>
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<nav>
<For each={serviceInstances()}>
{(mapped) => (
<ServiceRoute
clanURI={ctx.clanURI}
id={mapped.id}
label={mapped.label}
instance={mapped.instance}
/>
)}
</For>
</nav>
</Accordion.Content>
</Accordion.Item>
);
};
export const SidebarBody = (props: SidebarProps) => {
const sectionLabels = (props.staticSections || []).map(
(section) => section.title,
);
// controls which sections are open by default
// we want them all to be open by default
const defaultAccordionValues = ["machines", "services", ...sectionLabels];
return ( return (
<div class="sidebar-body"> <div class="sidebar-body">
<Accordion <Accordion
@@ -85,66 +248,8 @@ export const SidebarBody = (props: SidebarProps) => {
multiple multiple
defaultValue={defaultAccordionValues} defaultValue={defaultAccordionValues}
> >
<Accordion.Item class="item" value="your-machines"> <Machines />
<Accordion.Header class="header"> <Services />
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted={true}
color="tertiary"
>
Your Machines
</Typography>
<Icon
icon="CaretDown"
color="tertiary"
inverted={true}
size="0.75rem"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<Show
when={machines()}
fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
No machines yet
</Typography>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.name || id}
serviceCount={0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content>
</Accordion.Item>
<For each={props.staticSections}> <For each={props.staticSections}>
{(section) => ( {(section) => (
@@ -156,7 +261,7 @@ export const SidebarBody = (props: SidebarProps) => {
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="xs" size="xs"
inverted={true} inverted
color="tertiary" color="tertiary"
> >
{section.title} {section.title}
@@ -164,7 +269,7 @@ export const SidebarBody = (props: SidebarProps) => {
<Icon <Icon
icon="CaretDown" icon="CaretDown"
color="tertiary" color="tertiary"
inverted={true} inverted
size="0.75rem" size="0.75rem"
/> />
</Accordion.Trigger> </Accordion.Trigger>
@@ -179,7 +284,7 @@ export const SidebarBody = (props: SidebarProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted={true} inverted
> >
{link.label} {link.label}
</Typography> </Typography>

View File

@@ -13,6 +13,7 @@ import * as v from "valibot";
import { splitProps } from "solid-js"; import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags"; import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
type Story = StoryObj<SidebarPaneProps>; type Story = StoryObj<SidebarPaneProps>;
@@ -137,18 +138,21 @@ export const Default: Story = {
console.log("saving tags", values); console.log("saving tags", values);
}} }}
> >
{({ editing, Field }) => ( {({ editing, Field, formStore }) => (
<Field name="tags" type="string[]"> <Field name="tags" type="string[]">
{(field, input) => ( {(field, props) => (
<MachineTags <MachineTags
{...splitProps(field, ["value"])[1]} {...splitProps(field, ["value"])[1]}
size="s" size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted inverted
required required
readOnly={!editing} readOnly={!editing}
orientation="horizontal" orientation="horizontal"
defaultValue={field.value} defaultValue={field.value}
input={input}
/> />
)} )}
</Field> </Field>

View File

@@ -2,6 +2,7 @@ import { createSignal, JSX, Show } from "solid-js";
import { import {
createForm, createForm,
FieldValues, FieldValues,
FormStore,
getErrors, getErrors,
Maybe, Maybe,
PartialValues, PartialValues,
@@ -25,6 +26,7 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
children: (ctx: { children: (ctx: {
editing: boolean; editing: boolean;
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"]; Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
formStore: FormStore<FormValues>;
}) => JSX.Element; }) => JSX.Element;
} }
@@ -51,6 +53,8 @@ export function SidebarSectionForm<
}; };
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => { const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
console.log("Submitting SidebarForm", values);
await props.onSubmit(values); await props.onSubmit(values);
setEditing(false); setEditing(false);
}; };
@@ -109,7 +113,7 @@ export function SidebarSectionForm<
</Typography> </Typography>
</div> </div>
</Show> </Show>
{props.children({ editing: editing(), Field })} {props.children({ editing: editing(), Field, formStore })}
</div> </div>
</div> </div>
</Form> </Form>

View File

@@ -5,6 +5,7 @@ import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css"; import styles from "./SidebarSectionInstall.module.css";
import { Alert } from "../Alert/Alert"; import { Alert } from "../Alert/Alert";
import { useClanContext } from "@/src/routes/Clan/Clan";
export interface SidebarSectionInstallProps { export interface SidebarSectionInstallProps {
clanURI: string; clanURI: string;
@@ -12,8 +13,8 @@ export interface SidebarSectionInstallProps {
} }
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => { export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
const ctx = useClanContext();
const query = useMachineStateQuery(props.clanURI, props.machineName); const query = useMachineStateQuery(props.clanURI, props.machineName);
const [showInstall, setShowModal] = createSignal(false); const [showInstall, setShowModal] = createSignal(false);
return ( return (
@@ -32,7 +33,20 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
<InstallModal <InstallModal
open={showInstall()} open={showInstall()}
machineName={useMachineName()} machineName={useMachineName()}
onClose={() => setShowModal(false)} onClose={async () => {
// trigger machine state refresh and wait for it
const machineState = useMachineStateQuery(
props.clanURI,
props.machineName,
);
await machineState.refetch();
// trigger more state to refresh but not wait for it
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
setShowModal(false);
}}
/> />
</Show> </Show>
</div> </div>

View File

@@ -0,0 +1,46 @@
import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button";
import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css";
import { UpdateModal } from "@/src/workflows/InstallMachine/UpdateMachine";
import { useClanContext } from "@/src/routes/Clan/Clan";
export interface SidebarSectionUpdateProps {
clanURI: string;
machineName: string;
}
export const SidebarSectionUpdate = (props: SidebarSectionUpdateProps) => {
const ctx = useClanContext();
const query = useMachineStateQuery(props.clanURI, props.machineName);
const [showUpdate, setShowUpdate] = createSignal(false);
return (
<Show when={query.isSuccess && query.data.status !== "not_installed"}>
<div class={styles.install}>
<Button
hierarchy="primary"
size="s"
onClick={() => setShowUpdate(true)}
>
Update machine
</Button>
<Show when={showUpdate()}>
<UpdateModal
open={showUpdate()}
machineName={useMachineName()}
onClose={async () => {
// refresh some queries
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
setShowUpdate(false);
}}
/>
</Show>
</div>
</Show>
);
};

View File

@@ -1,6 +1,6 @@
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan"; import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { Params, Navigator, useParams } from "@solidjs/router"; import { Params, Navigator, useParams, useSearchParams } from "@solidjs/router";
export const encodeBase64 = (value: string) => window.btoa(value); export const encodeBase64 = (value: string) => window.btoa(value);
export const decodeBase64 = (value: string) => window.atob(value); export const decodeBase64 = (value: string) => window.atob(value);
@@ -30,6 +30,47 @@ export const buildClanPath = (clanURI: string) => {
export const buildMachinePath = (clanURI: string, name: string) => export const buildMachinePath = (clanURI: string, name: string) =>
buildClanPath(clanURI) + "/machines/" + name; buildClanPath(clanURI) + "/machines/" + name;
export const buildServicePath = (props: {
clanURI: string;
id: string;
module: {
name: string;
input?: string | null | undefined;
};
}) => {
const { clanURI, id, module } = props;
const moduleName = encodeBase64(module.name);
const idEncoded = encodeBase64(id);
const result =
buildClanPath(clanURI) +
`/services/${moduleName}/${idEncoded}` +
(module.input ? `?input=${module.input}` : "");
return result;
};
export const useServiceParams = () => {
const params = useParams<{
name?: string;
id?: string;
}>();
const [search] = useSearchParams<{ input?: string }>();
if (!params.name || !params.id) {
console.error("Service params not found", params, window.location.pathname);
throw new Error("Service params not found");
}
return {
name: decodeBase64(params.name),
id: decodeBase64(params.id),
input: search.input,
};
};
export const navigateToClan = (navigate: Navigator, clanURI: string) => { export const navigateToClan = (navigate: Navigator, clanURI: string) => {
const path = buildClanPath(clanURI); const path = buildClanPath(clanURI);
console.log("Navigating to clan", clanURI, path); console.log("Navigating to clan", clanURI, path);
@@ -64,7 +105,21 @@ export const machineNameParam = (params: Params) => {
return params.machineName; return params.machineName;
}; };
export const inputParam = (params: Params) => params.input;
export const nameParam = (params: Params) => params.name;
export const idParam = (params: Params) => params.id;
export const useMachineName = (): string => machineNameParam(useParams()); export const useMachineName = (): string => machineNameParam(useParams());
export const useInputParam = (): string => inputParam(useParams());
export const useNameParam = (): string => nameParam(useParams());
export const maybeUseIdParam = (): string | null => {
const params = useParams();
if (params.id === undefined) {
return null;
}
return idParam(params);
};
export const maybeUseMachineName = (): string | null => { export const maybeUseMachineName = (): string | null => {
const params = useParams(); const params = useParams();

View File

@@ -25,6 +25,9 @@ export type MachineStatus = MachineState["status"];
export type ListMachines = SuccessData<"list_machines">; export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">; export type MachineDetails = SuccessData<"get_machine_details">;
export type ListServiceModules = SuccessData<"list_service_modules">;
export type ListServiceInstances = SuccessData<"list_service_instances">;
export interface MachineDetail { export interface MachineDetail {
tags: Tags; tags: Tags;
machine: Machine; machine: Machine;
@@ -47,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ListMachines>(() => ({ return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"], queryKey: [...clanKey(clanURI), "machines"],
queryFn: async () => { queryFn: async () => {
const api = client.fetch("list_machines", { const api = client.fetch("list_machines", {
flake: { flake: {
@@ -64,10 +67,16 @@ export const useMachinesQuery = (clanURI: string) => {
})); }));
}; };
export const machineKey = (clanUri: string, machineName: string) => [
...clanKey(clanUri),
"machine",
encodeBase64(machineName),
];
export const useMachineQuery = (clanURI: string, machineName: string) => { export const useMachineQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineDetail>(() => ({ return useQuery<MachineDetail>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], queryKey: [machineKey(clanURI, machineName)],
queryFn: async () => { queryFn: async () => {
const [tagsCall, machineCall, schemaCall] = [ const [tagsCall, machineCall, schemaCall] = [
client.fetch("list_tags", { client.fetch("list_tags", {
@@ -122,7 +131,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
export const useTags = (clanURI: string) => { export const useTags = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanURI), "tags"], queryKey: [...clanKey(clanURI), "tags"],
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("list_tags", { const apiCall = client.fetch("list_tags", {
flake: { flake: {
@@ -142,8 +151,7 @@ export const useTags = (clanURI: string) => {
export const useMachineStateQuery = (clanURI: string, machineName: string) => { export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineState>(() => ({ return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"], queryKey: [...machineKey(clanURI, machineName), "state"],
staleTime: 60_000, // 1 minute stale time
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("get_machine_state", { const apiCall = client.fetch("get_machine_state", {
machine: { machine: {
@@ -166,13 +174,61 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
})); }));
}; };
export const useServiceModulesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListServiceModules>(() => ({
queryKey: [...clanKey(clanURI), "service_modules"],
queryFn: async () => {
const call = client.fetch("list_service_modules", {
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(
"Error fetching service modules: " + result.errors[0].message,
);
}
return result.data;
},
}));
};
export const useServiceInstancesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListServiceInstances>(() => ({
queryKey: [...clanKey(clanURI), "service_instances"],
queryFn: async () => {
const call = client.fetch("list_service_instances", {
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(
"Error fetching service instances: " + result.errors[0].message,
);
}
return result.data;
},
}));
};
export const useMachineDetailsQuery = ( export const useMachineDetailsQuery = (
clanURI: string, clanURI: string,
machineName: string, machineName: string,
) => { ) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineDetails>(() => ({ return useQuery<MachineDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName], queryKey: [machineKey(clanURI, machineName), "details"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_machine_details", { const call = client.fetch("get_machine_details", {
machine: { machine: {
@@ -202,7 +258,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
export const useClanDetailsQuery = (clanURI: string) => { export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ClanDetails>(() => ({ return useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: [...clanKey(clanURI), "details"],
persister: ClanDetailsPersister.persisterFn, persister: ClanDetailsPersister.persisterFn,
queryFn: async () => { queryFn: async () => {
const args = { const args = {
@@ -253,7 +309,8 @@ export const useClanListQuery = (
return useQueries(() => ({ return useQueries(() => ({
queries: clanURIs.map((clanURI) => { queries: clanURIs.map((clanURI) => {
const queryKey = ["clans", encodeBase64(clanURI), "details"]; // @BMG: Is duplicating query key intentional?
const queryKey = [...clanKey(clanURI), "details"];
return { return {
// eslint-disable-next-line @tanstack/query/exhaustive-deps // eslint-disable-next-line @tanstack/query/exhaustive-deps
@@ -322,7 +379,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => { export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineFlashOptions>(() => ({ return useQuery<MachineFlashOptions>(() => ({
queryKey: ["clans", "machine_flash_options"], queryKey: ["flash_options"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_machine_flash_options", {}); const call = client.fetch("get_machine_flash_options", {});
const result = await call.result; const result = await call.result;
@@ -456,12 +513,14 @@ export const useMachineGenerators = (
], ],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_generators", { const call = client.fetch("get_generators", {
machine: { machines: [
name: machineName, {
flake: { name: machineName,
identifier: clanUri, flake: {
identifier: clanUri,
},
}, },
}, ],
full_closure: true, // TODO: Make this configurable full_closure: true, // TODO: Make this configurable
// TODO: Make this configurable // TODO: Make this configurable
include_previous_values: true, include_previous_values: true,
@@ -484,7 +543,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
export const useServiceModules = (clanUri: string) => { export const useServiceModules = (clanUri: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_modules"], queryKey: [...clanKey(clanUri), "service_modules"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("list_service_modules", { const call = client.fetch("list_service_modules", {
flake: { flake: {
@@ -504,12 +563,14 @@ export const useServiceModules = (clanUri: string) => {
})); }));
}; };
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>; export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
export type ServiceInstances = SuccessData<"list_service_instances">; export type ServiceInstances = SuccessData<"list_service_instances">;
export const useServiceInstances = (clanUri: string) => { export const useServiceInstances = (clanUri: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_instances"], queryKey: [...clanKey(clanUri), "service_instances"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("list_service_instances", { const call = client.fetch("list_service_instances", {
flake: { flake: {

View File

@@ -1,4 +1,4 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router"; import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
createContext, createContext,
@@ -17,13 +17,15 @@ import {
useClanURI, useClanURI,
useMachineName, useMachineName,
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes"; import { CubeScene } from "@/src/scene/cubes";
import { import {
ClanDetails, ClanDetails,
ListServiceInstances,
MachinesQueryResult, MachinesQueryResult,
useClanDetailsQuery, useClanDetailsQuery,
useClanListQuery, useClanListQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstancesQuery,
} from "@/src/hooks/queries"; } from "@/src/hooks/queries";
import { clanURIs, setStore, store } from "@/src/stores/clan"; import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store"; import { produce } from "solid-js/store";
@@ -33,37 +35,27 @@ import styles from "./Clan.module.css";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; 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";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine"; import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
interface ClanContextProps { export type WorldMode = "default" | "select" | "service" | "create" | "move";
clanURI: string;
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetails>;
otherClanQueries: UseQueryResult<ClanDetails>[];
allClansQueries: UseQueryResult<ClanDetails>[];
isLoading(): boolean;
isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
}
function createClanContext( function createClanContext(
clanURI: string, clanURI: string,
machinesQuery: MachinesQueryResult, machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetails>, activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetails>[], otherClanQueries: UseQueryResult<ClanDetails>[],
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
) { ) {
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
const [showAddMachine, setShowAddMachine] = createSignal(false); const [showAddMachine, setShowAddMachine] = createSignal(false);
const navigate = useNavigate();
const location = useLocation();
const allClansQueries = [activeClanQuery, ...otherClanQueries]; const allClansQueries = [activeClanQuery, ...otherClanQueries];
const allQueries = [machinesQuery, ...allClansQueries]; const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
return { return {
clanURI, clanURI,
@@ -71,14 +63,23 @@ function createClanContext(
activeClanQuery, activeClanQuery,
otherClanQueries, otherClanQueries,
allClansQueries, allClansQueries,
serviceInstancesQuery,
isLoading: () => allQueries.some((q) => q.isLoading), isLoading: () => allQueries.some((q) => q.isLoading),
isError: () => activeClanQuery.isError, isError: () => activeClanQuery.isError,
showAddMachine, showAddMachine,
setShowAddMachine, setShowAddMachine,
navigateToRoot: () => {
if (location.pathname === buildClanPath(clanURI)) return;
navigate(buildClanPath(clanURI), { replace: true });
},
setWorldMode,
worldMode,
}; };
} }
const ClanContext = createContext<ClanContextProps>(); const ClanContext = createContext<
ReturnType<typeof createClanContext> | undefined
>();
export const useClanContext = () => { export const useClanContext = () => {
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
@@ -104,12 +105,14 @@ export const Clan: Component<RouteSectionProps> = (props) => {
); );
const machinesQuery = useMachinesQuery(clanURI); const machinesQuery = useMachinesQuery(clanURI);
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
const ctx = createClanContext( const ctx = createClanContext(
clanURI, clanURI,
machinesQuery, machinesQuery,
activeClanQuery, activeClanQuery,
otherClanQueries, otherClanQueries,
serviceInstancesQuery,
); );
return ( return (
@@ -132,8 +135,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [showService, setShowService] = createSignal(false);
const [currentPromise, setCurrentPromise] = createSignal<{ const [currentPromise, setCurrentPromise] = createSignal<{
resolve: ({ id }: { id: string }) => void; resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void; reject: (err: unknown) => void;
@@ -194,45 +195,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
}), }),
); );
const client = useApiClient(); const location = useLocation();
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");
setShowService(false);
setWorldMode("select");
};
createEffect(
on(worldMode, (mode) => {
if (mode === "service") {
setShowService(true);
} else {
// TODO: request soft close instead of forced close
setShowService(false);
}
}),
);
return ( return (
<> <>
@@ -268,15 +231,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
isLoading={ctx.isLoading()} isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery} cubesQuery={ctx.machinesQuery}
toolbarPopup={ toolbarPopup={
<Show when={showService()}> <Show when={ctx.worldMode() === "service"}>
<ServiceWorkflow <Show
handleSubmit={handleSubmitService} when={location.pathname.includes("/services/")}
onClose={() => { fallback={
setShowService(false); <SelectService
setWorldMode("select"); onClose={() => {
currentPromise()?.resolve({ id: "0" }); ctx.setWorldMode("select");
}} }}
/> />
}
>
{props.children}
</Show>
</Show> </Show>
} }
onCreate={onCreate} onCreate={onCreate}

View File

@@ -6,10 +6,11 @@ import { SectionGeneral } from "./SectionGeneral";
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries"; import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags"; import { SectionTags } from "@/src/routes/Machine/SectionTags";
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall"; import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
import styles from "./Machine.module.css"; import styles from "./Machine.module.css";
import { SectionServices } from "@/src/routes/Machine/SectionServices";
import { SidebarSectionUpdate } from "@/src/components/Sidebar/SidebarSectionUpdate";
export const Machine = (props: RouteSectionProps) => { export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -20,13 +21,16 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const sections = () => { const Sections = () => {
const machineName = useMachineName(); const machineName = useMachineName();
const machineQuery = useMachineQuery(clanURI, machineName); const machineQuery = useMachineQuery(clanURI, machineName);
console.log("machineName", machineName);
// we have to update the whole machine model rather than just the sub fields that were changed // we have to update the whole machine model rather than just the sub fields that were changed
// for that reason we pass in this common submit handler to each machine sub section // for that reason we pass in this common submit handler to each machine sub section
const onSubmit = async (values: Partial<MachineModel>) => { const onSubmit = async (values: Partial<MachineModel>) => {
console.log("saving tags", values);
const call = callApi("set_machine", { const call = callApi("set_machine", {
machine: { machine: {
name: machineName, name: machineName,
@@ -57,8 +61,13 @@ export const Machine = (props: RouteSectionProps) => {
clanURI={clanURI} clanURI={clanURI}
machineName={useMachineName()} machineName={useMachineName()}
/> />
<SidebarSectionUpdate
clanURI={clanURI}
machineName={useMachineName()}
/>
<SectionGeneral {...sectionProps} /> <SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} /> <SectionTags {...sectionProps} />
<SectionServices />
</> </>
); );
}; };
@@ -69,16 +78,19 @@ export const Machine = (props: RouteSectionProps) => {
<SidebarPane <SidebarPane
title={useMachineName()} title={useMachineName()}
onClose={onClose} onClose={onClose}
subHeader={ // the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and
<Show when={useMachineName()} keyed> // present the user with the ability to install or update a machines based on `installedAt` in the inventory.json
<SidebarMachineStatus //
clanURI={clanURI} // subHeader={
machineName={useMachineName()} // <Show when={useMachineName()} keyed>
/> // <SidebarMachineStatus
</Show> // clanURI={clanURI}
} // machineName={useMachineName()}
// />
// </Show>
// }
> >
{sections()} {Sections()}
</SidebarPane> </SidebarPane>
</div> </div>
</Show> </Show>

View File

@@ -0,0 +1,41 @@
.sectionServices {
@apply overflow-hidden flex flex-col;
@apply bg-inv-4 rounded-md;
nav * {
@apply outline-none;
}
nav > a {
@apply block w-full px-2 py-1.5 min-h-7 my-2 rounded-md;
&:first-child {
@apply mt-0;
}
&:last-child {
@apply mb-0;
}
&:focus-visible {
background: linear-gradient(
90deg,
theme(colors.secondary.900),
60%,
theme(colors.secondary.600) 100%
);
}
&:hover {
@apply bg-inv-acc-2;
}
&:active {
@apply bg-inv-acc-3;
}
&.active {
@apply bg-inv-acc-2;
}
}
}

View File

@@ -0,0 +1,53 @@
import { SidebarSection } from "@/src/components/Sidebar/SidebarSection";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { For, Show } from "solid-js";
import { useMachineName } from "@/src/hooks/clan";
import { ServiceRoute } from "@/src/components/Sidebar/SidebarBody";
import styles from "./SectionServices.module.css";
export const SectionServices = () => {
const ctx = useClanContext();
const services = () => {
if (!(ctx.machinesQuery.isSuccess && ctx.serviceInstancesQuery.isSuccess)) {
return [];
}
const machineName = useMachineName();
if (!ctx.machinesQuery.data[machineName]) {
return [];
}
return (ctx.machinesQuery.data[machineName].instance_refs ?? [])
.map((id) => {
const instance = ctx.serviceInstancesQuery.data?.[id];
if (!instance) {
throw new Error(`Service instance ${id} not found`);
}
const module = instance.module;
return {
id,
instance,
label: module.name == id ? module.name : `${module.name} (${id})`,
};
})
.sort((a, b) => a.label.localeCompare(b.label));
};
return (
<Show when={ctx.serviceInstancesQuery.isSuccess}>
<SidebarSection title="Services">
<div class={styles.sectionServices}>
<nav>
<For each={services()}>
{(instance) => (
<ServiceRoute clanURI={ctx.clanURI} {...instance} />
)}
</For>
</nav>
</div>
</SidebarSection>
</Show>
);
};

View File

@@ -5,6 +5,7 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
import { pick } from "@/src/util"; import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { MachineTags } from "@/src/components/Form/MachineTags"; import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
const schema = v.object({ const schema = v.object({
tags: v.pipe(v.optional(v.array(v.string()))), tags: v.pipe(v.optional(v.array(v.string()))),
@@ -32,7 +33,7 @@ export const SectionTags = (props: SectionTags) => {
const options = () => { const options = () => {
if (!machineQuery.isSuccess) { if (!machineQuery.isSuccess) {
return [[], []]; return [];
} }
// these are static values or values which have been configured in nix and // these are static values or values which have been configured in nix and
@@ -58,7 +59,7 @@ export const SectionTags = (props: SectionTags) => {
onSubmit={props.onSubmit} onSubmit={props.onSubmit}
initialValues={initialValues()} initialValues={initialValues()}
> >
{({ editing, Field }) => ( {({ editing, Field, formStore }) => (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<Field name="tags" type="string[]"> <Field name="tags" type="string[]">
{(field, input) => ( {(field, input) => (
@@ -72,7 +73,10 @@ export const SectionTags = (props: SectionTags) => {
defaultValue={field.value} defaultValue={field.value}
defaultOptions={options()[0]} defaultOptions={options()[0]}
readonlyOptions={options()[1]} readonlyOptions={options()[1]}
input={input} onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
/> />
)} )}
</Field> </Field>

View File

@@ -0,0 +1,59 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { ServiceWorkflow } from "@/src/workflows/Service/Service";
import { SubmitServiceHandler } from "@/src/workflows/Service/models";
import { buildClanPath } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
import { useQueryClient } from "@tanstack/solid-query";
import { clanKey } from "@/src/hooks/queries";
import { onMount } from "solid-js";
export const Service = (props: RouteSectionProps) => {
const ctx = useClanContext();
const navigate = useNavigate();
const client = useApiClient();
const queryClient = useQueryClient();
onMount(() => {
ctx.setWorldMode("service");
});
const handleSubmit: SubmitServiceHandler = async (instance, action) => {
console.log("Service submitted", instance, action);
if (action !== "create") {
console.warn("Updating service instances is not supported yet");
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") {
console.error("Error creating service instance", result.errors);
}
queryClient.invalidateQueries({
queryKey: clanKey(ctx.clanURI),
});
ctx.setWorldMode("select");
};
const handleClose = () => {
console.log("Service closed, navigating back");
navigate(buildClanPath(ctx.clanURI), { replace: true });
ctx.setWorldMode("select");
};
return <ServiceWorkflow handleSubmit={handleSubmit} onClose={handleClose} />;
};

View File

@@ -2,6 +2,7 @@ import type { RouteDefinition } from "@solidjs/router/dist/types";
import { Onboarding } from "@/src/routes/Onboarding/Onboarding"; import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
import { Clan } from "@/src/routes/Clan/Clan"; import { Clan } from "@/src/routes/Clan/Clan";
import { Machine } from "@/src/routes/Machine/Machine"; import { Machine } from "@/src/routes/Machine/Machine";
import { Service } from "@/src/routes/Service/Service";
export const Routes: RouteDefinition[] = [ export const Routes: RouteDefinition[] = [
{ {
@@ -30,6 +31,15 @@ export const Routes: RouteDefinition[] = [
{ {
path: "/machines/:machineName", path: "/machines/:machineName",
component: Machine, component: Machine,
children: [
{
path: "/",
},
],
},
{
path: "/services/:name/:id",
component: Service,
}, },
], ],
}, },

View File

@@ -1,11 +1,10 @@
import * as THREE from "three"; import * as THREE from "three";
import { ObjectRegistry } from "./ObjectRegistry"; import { ObjectRegistry } from "./ObjectRegistry";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Accessor, createEffect, createRoot, on } from "solid-js"; import { Accessor, createEffect, createRoot, on } from "solid-js";
import { renderLoop } from "./RenderLoop"; import { renderLoop } from "./RenderLoop";
// @ts-expect-error: No types for troika-three-text import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
import { Text } from "troika-three-text"; import { FontLoader } from "three/examples/jsm/Addons";
import ttf from "../../.fonts/CommitMonoV143-VF.ttf"; import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
// Constants // Constants
const BASE_SIZE = 0.9; const BASE_SIZE = 0.9;
@@ -23,6 +22,71 @@ const BASE_EMISSIVE = 0x0c0c0c;
const BASE_SELECTED_COLOR = 0x69b0e3; const BASE_SELECTED_COLOR = 0x69b0e3;
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
export function createMachineMesh() {
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
const material = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
shininess: 100,
transparent: true,
});
const cubeMesh = new THREE.Mesh(geometry, material);
cubeMesh.castShadow = true;
cubeMesh.receiveShadow = true;
cubeMesh.name = "cube";
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
const { baseMesh, baseMaterial } = createCubeBase(
BASE_COLOR,
BASE_EMISSIVE,
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
);
return {
cubeMesh,
baseMesh,
baseMaterial,
geometry,
material,
};
}
export function createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
geometry: THREE.BoxGeometry,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
color,
emissive,
transparent: true,
opacity: 1,
});
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
baseMesh.receiveShadow = false;
return { baseMesh, baseMaterial };
}
// Function to build rounded rect shape
export function roundedRectShape(w: number, h: number, r: number) {
const shape = new THREE.Shape();
const x = -w / 2;
const y = -h / 2;
shape.moveTo(x + r, y);
shape.lineTo(x + w - r, y);
shape.quadraticCurveTo(x + w, y, x + w, y + r);
shape.lineTo(x + w, y + h - r);
shape.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
shape.lineTo(x + r, y + h);
shape.quadraticCurveTo(x, y + h, x, y + h - r);
shape.lineTo(x, y + r);
shape.quadraticCurveTo(x, y, x + r, y);
return shape;
}
export class MachineRepr { export class MachineRepr {
public id: string; public id: string;
public group: THREE.Group; public group: THREE.Group;
@@ -46,31 +110,21 @@ export class MachineRepr {
) { ) {
this.id = id; this.id = id;
this.camera = camera; this.camera = camera;
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
this.material = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
shininess: 100,
});
this.cubeMesh = new THREE.Mesh(this.geometry, this.material); const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
this.cubeMesh.castShadow = true; this.cubeMesh = cubeMesh;
this.cubeMesh.receiveShadow = true;
this.cubeMesh.userData = { id }; this.cubeMesh.userData = { id };
this.cubeMesh.name = "cube";
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
this.baseMesh = this.createCubeBase( this.baseMesh = baseMesh;
BASE_COLOR,
BASE_EMISSIVE,
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
);
this.baseMesh.name = "base"; this.baseMesh.name = "base";
this.geometry = geometry;
this.material = material;
const label = this.createLabel(id); const label = this.createLabel(id);
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({ const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
color: BASE_COLOR, // any color you like color: BASE_COLOR,
roughness: 1, roughness: 1,
metalness: 0, metalness: 0,
transparent: true, transparent: true,
@@ -104,8 +158,6 @@ export class MachineRepr {
const highlightedGroups = groups const highlightedGroups = groups
.filter(([, ids]) => ids.has(this.id)) .filter(([, ids]) => ids.has(this.id))
.map(([name]) => name); .map(([name]) => name);
// console.log("MachineRepr effect", id, highlightedGroups);
// Update cube // Update cube
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set( (this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR, isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
@@ -122,9 +174,6 @@ export class MachineRepr {
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set( (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000, highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
); );
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
// );
renderLoop.requestRender(); renderLoop.requestRender();
}, },
@@ -149,45 +198,85 @@ export class MachineRepr {
renderLoop.requestRender(); renderLoop.requestRender();
} }
private createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
geometry: THREE.BoxGeometry,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
color,
emissive,
transparent: true,
opacity: 1,
});
const base = new THREE.Mesh(geometry, baseMaterial);
base.position.set(0, BASE_HEIGHT / 2, 0);
base.receiveShadow = false;
return base;
}
private createLabel(id: string) { private createLabel(id: string) {
const text = new Text(); const group = new THREE.Group();
text.text = id; // 0x162324
text.font = ttf; // const text = new Text();
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON // text.text = id;
text.fontSize = 0.15; // relative to your cube size // text.font = ttf;
text.color = 0x000000; // any THREE.Color // text.fontSize = 0.1;
text.anchorX = "center"; // horizontal centering // text.color = 0xffffff;
text.anchorY = "bottom"; // baseline aligns to cube top // text.anchorX = "center";
text.position.set(0, CUBE_SIZE + 0.05, 0); // text.anchorY = "middle";
// text.position.set(0, 0, 0.01);
// text.outlineWidth = 0.005;
// text.outlineColor = 0x162324;
// text.sync(() => {
// renderLoop.requestRender();
// });
// If you want it to always face camera: const textMaterial = new THREE.MeshPhongMaterial({
text.userData.isLabel = true; color: 0xffffff,
text.outlineWidth = 0.005;
text.outlineColor = 0x333333;
text.quaternion.copy(this.camera.quaternion);
// Re-render on text changes
text.sync(() => {
renderLoop.requestRender();
}); });
return text; const textGeo = new TextGeometry(id, {
font: new FontLoader().parse(jsonfont),
size: 0.09,
depth: 0.001,
curveSegments: 12,
bevelEnabled: false,
});
const text = new THREE.Mesh(textGeo, textMaterial);
textGeo.computeBoundingBox();
const bbox = textGeo.boundingBox;
if (bbox) {
const xMid = -0.5 * (bbox.max.x - bbox.min.x);
// const yMid = -0.5 * (bbox.max.y - bbox.min.y);
// const zMid = -0.5 * (bbox.max.z - bbox.min.z);
// Translate geometry so center is at origin / baseline aligned with y=0
textGeo.translate(xMid, -0.035, 0);
}
// --- Background (rounded rect) ---
const padding = 0.04;
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1;
const bgWidth = textWidth + 10 * padding;
// const bgWidth = text.text.length * 0.07 + padding;
const bgHeight = 0.1 + 2 * padding;
const radius = 0.02;
const bgShape = roundedRectShape(bgWidth, bgHeight, radius);
const bgGeom = new THREE.ShapeGeometry(bgShape);
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 });
const bg = new THREE.Mesh(bgGeom, bgMat);
bg.position.set(0, 0, -0.01);
// --- Arrow (triangle pointing down) ---
const arrowShape = new THREE.Shape();
arrowShape.moveTo(-0.05, 0);
arrowShape.lineTo(0.05, 0);
arrowShape.lineTo(0, -0.05);
arrowShape.closePath();
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
const arrow = new THREE.Mesh(arrowGeom, bgMat);
arrow.position.set(0, -bgHeight / 2, -0.001);
// --- Group ---
group.add(bg);
group.add(arrow);
group.add(text);
// Position above cube
group.position.set(0, CUBE_SIZE + 0.3, 0);
// Billboard
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
group.quaternion.copy(this.camera.quaternion);
return group;
} }
dispose(scene: THREE.Scene) { dispose(scene: THREE.Scene) {
@@ -197,12 +286,13 @@ export class MachineRepr {
this.geometry.dispose(); this.geometry.dispose();
this.material.dispose(); this.material.dispose();
this.group.clear();
for (const child of this.cubeMesh.children) { for (const child of this.cubeMesh.children) {
if (child instanceof THREE.Mesh) if (child instanceof THREE.Mesh)
(child.material as THREE.Material).dispose(); (child.material as THREE.Material).dispose();
if (child instanceof CSS2DObject) child.element.remove();
if (child instanceof THREE.Object3D) child.remove(); if (child instanceof THREE.Object3D) child.remove();
} }
(this.baseMesh.material as THREE.Material).dispose(); (this.baseMesh.material as THREE.Material).dispose();

View File

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

View File

@@ -25,7 +25,13 @@ import { MachineManager } from "./MachineManager";
import cx from "classnames"; import cx from "classnames";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { Menu } from "../components/ContextMenu/ContextMenu"; import { Menu } from "../components/ContextMenu/ContextMenu";
import { clearHighlight, setHighlightGroups } from "./highlightStore"; import {
clearHighlight,
highlightGroups,
setHighlightGroups,
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
function intersectMachines( function intersectMachines(
event: MouseEvent, event: MouseEvent,
@@ -33,7 +39,7 @@ function intersectMachines(
camera: THREE.Camera, camera: THREE.Camera,
machineManager: MachineManager, machineManager: MachineManager,
raycaster: THREE.Raycaster, raycaster: THREE.Raycaster,
): string[] { ) {
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2( const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1, ((event.clientX - rect.left) / rect.width) * 2 - 1,
@@ -44,7 +50,10 @@ function intersectMachines(
Array.from(machineManager.machines.values().map((m) => m.group)), Array.from(machineManager.machines.values().map((m) => m.group)),
); );
return intersects.map((i) => i.object.userData.id); return {
machines: intersects.map((i) => i.object.userData.id),
intersection: intersects,
};
} }
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
@@ -86,12 +95,6 @@ export function useMachineClick() {
return lastClickedMachine; return lastClickedMachine;
} }
/*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" | "move"
>("select");
export { worldMode, setWorldMode };
export function CubeScene(props: { export function CubeScene(props: {
cubesQuery: MachinesQueryResult; cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>; onCreate: () => Promise<{ id: string }>;
@@ -103,6 +106,8 @@ export function CubeScene(props: {
clanURI: string; clanURI: string;
toolbarPopup?: JSX.Element; toolbarPopup?: JSX.Element;
}) { }) {
const ctx = useClanContext();
let container: HTMLDivElement; let container: HTMLDivElement;
let scene: THREE.Scene; let scene: THREE.Scene;
let camera: THREE.OrthographicCamera; let camera: THREE.OrthographicCamera;
@@ -113,6 +118,7 @@ export function CubeScene(props: {
// Raycaster for clicking // Raycaster for clicking
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
let actionBase: THREE.Mesh | undefined; let actionBase: THREE.Mesh | undefined;
let actionMachine: THREE.Group | undefined;
// Create background scene // Create background scene
const bgScene = new THREE.Scene(); const bgScene = new THREE.Scene();
@@ -123,12 +129,17 @@ export function CubeScene(props: {
let sharedCubeGeometry: THREE.BoxGeometry; let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry; let sharedBaseGeometry: THREE.BoxGeometry;
let machineManager: MachineManager;
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid", "grid",
); );
// Managed by controls // Managed by controls
const [isDragging, setIsDragging] = createSignal(false); const [isDragging, setIsDragging] = createSignal(false);
const [cancelMove, setCancelMove] = createSignal<NodeJS.Timeout>();
// TODO: Unify this with actionRepr position
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>(); const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [cameraInfo, setCameraInfo] = createSignal({ const [cameraInfo, setCameraInfo] = createSignal({
@@ -300,12 +311,12 @@ export function CubeScene(props: {
bgCamera, bgCamera,
); );
controls.addEventListener("start", (e) => { // controls.addEventListener("start", (e) => {
setIsDragging(true); // setIsDragging(true);
}); // });
controls.addEventListener("end", (e) => { // controls.addEventListener("end", (e) => {
setIsDragging(false); // setIsDragging(false);
}); // });
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72); const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
@@ -384,6 +395,23 @@ export function CubeScene(props: {
scene.add(actionBase); scene.add(actionBase);
function createActionMachine() {
const { baseMesh, cubeMesh, material, baseMaterial } =
createMachineMesh();
const group = new THREE.Group();
group.add(baseMesh);
group.add(cubeMesh);
// group.scale.set(0.75, 0.75, 0.75);
material.opacity = 0.6;
baseMaterial.opacity = 0.3;
baseMaterial.emissive.set(MOVE_BASE_EMISSIVE);
// Hide until needed
group.visible = false;
return group;
}
actionMachine = createActionMachine();
scene.add(actionMachine);
// const spherical = new THREE.Spherical(); // const spherical = new THREE.Spherical();
// spherical.setFromVector3(camera.position); // spherical.setFromVector3(camera.position);
@@ -409,7 +437,7 @@ export function CubeScene(props: {
updateCameraInfo(); updateCameraInfo();
createEffect( createEffect(
on(worldMode, (mode) => { on(ctx.worldMode, (mode) => {
if (mode === "create") { if (mode === "create") {
actionBase!.visible = true; actionBase!.visible = true;
} else { } else {
@@ -421,7 +449,7 @@ export function CubeScene(props: {
const registry = new ObjectRegistry(); const registry = new ObjectRegistry();
const machineManager = new MachineManager( machineManager = new MachineManager(
scene, scene,
registry, registry,
props.sceneStore, props.sceneStore,
@@ -435,7 +463,7 @@ export function CubeScene(props: {
// - Select/deselects a cube in mode // - Select/deselects a cube in mode
// - Creates a new cube in "create" mode // - Creates a new cube in "create" mode
const onClick = (event: MouseEvent) => { const onClick = (event: MouseEvent) => {
if (worldMode() === "create") { if (ctx.worldMode() === "create") {
props props
.onCreate() .onCreate()
.then(({ id }) => { .then(({ id }) => {
@@ -453,17 +481,16 @@ export function CubeScene(props: {
.finally(() => { .finally(() => {
if (actionBase) actionBase.visible = false; if (actionBase) actionBase.visible = false;
setWorldMode("default"); ctx.setWorldMode("select");
}); });
} }
if (worldMode() === "move") { if (ctx.worldMode() === "move") {
console.log("sanpped");
const currId = menuIntersection().at(0); const currId = menuIntersection().at(0);
const pos = cursorPosition(); const pos = cursorPosition();
if (!currId || !pos) return; if (!currId || !pos) return;
props.setMachinePos(currId, pos); props.setMachinePos(currId, pos);
setWorldMode("select"); ctx.setWorldMode("select");
clearHighlight("move"); clearHighlight("move");
} }
@@ -477,18 +504,20 @@ export function CubeScene(props: {
const intersects = raycaster.intersectObjects( const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)), Array.from(machineManager.machines.values().map((m) => m.group)),
); );
console.log("Intersects:", intersects);
if (intersects.length > 0) { if (intersects.length > 0) {
console.log("Clicked on cube:", intersects); const id = intersects.find((i) => i.object.userData?.id)?.object
const id = intersects[0].object.userData.id; .userData.id;
if (worldMode() === "select") props.onSelect(new Set<string>([id])); if (!id) return;
if (ctx.worldMode() === "select") props.onSelect(new Set<string>([id]));
console.log("Clicked on machine", id);
emitMachineClick(id); // notify subscribers emitMachineClick(id); // notify subscribers
} else { } else {
emitMachineClick(null); emitMachineClick(null);
if (worldMode() === "select") props.onSelect(new Set<string>()); if (ctx.worldMode() === "select") props.onSelect(new Set<string>());
} }
}; };
@@ -520,24 +549,73 @@ export function CubeScene(props: {
}; };
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
const { machines, intersection } = intersectMachines(
e,
renderer,
camera,
machineManager,
raycaster,
);
if (e.button === 0) {
// Left button
if (ctx.worldMode() === "select" && machines.length) {
// Disable controls to avoid conflict
controls.enabled = false;
// Change cursor to grabbing
// LongPress, if not canceled, enters move mode
const cancelMove = setTimeout(() => {
setIsDragging(true);
const pos =
machineManager.machines.get(machines[0])?.group.position ||
new THREE.Vector3(0, 0, 0);
actionMachine?.position.set(pos.x, 0, pos.z);
// Set machine as flying
setHighlightGroups({ move: new Set(machines) });
ctx.setWorldMode("move");
renderLoop.requestRender();
}, 500);
setCancelMove(cancelMove);
}
}
if (e.button === 2) { if (e.button === 2) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const intersection = intersectMachines(
e,
renderer,
camera,
machineManager,
raycaster,
);
if (!intersection.length) return; if (!intersection.length) return;
setMenuIntersection(intersection); setMenuIntersection(machines);
setMenuPos({ x: e.clientX, y: e.clientY }); setMenuPos({ x: e.clientX, y: e.clientY });
setContextOpen(true); setContextOpen(true);
} }
}; };
const handleMouseUp = (e: MouseEvent) => {
if (e.button === 0) {
setIsDragging(false);
if (cancelMove()) {
clearTimeout(cancelMove()!);
setCancelMove(undefined);
}
// Always re-enable controls
controls.enabled = true;
if (ctx.worldMode() === "move") {
// Set machine as not flying
const pos = actionMachine!.position.toArray();
props.setMachinePos(highlightGroups["move"].values().next().value!, [
pos[0], // x
pos[2], // z
]);
clearHighlight("move");
ctx.setWorldMode("select");
renderLoop.requestRender();
}
}
};
renderer.domElement.addEventListener("mousedown", handleMouseDown); renderer.domElement.addEventListener("mousedown", handleMouseDown);
renderer.domElement.addEventListener("mouseup", handleMouseUp);
renderer.domElement.addEventListener("mousemove", onMouseMove); renderer.domElement.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
@@ -582,21 +660,55 @@ export function CubeScene(props: {
}); });
}); });
const snapToGrid = (point: THREE.Vector3) => {
if (!props.sceneStore) return;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
0,
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
// Skip snapping if there's already a cube at this position
const positions = Object.entries(props.sceneStore());
const intersects = positions.some(
([_id, p]) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
const movingMachine = Array.from(highlightGroups["move"] || [])[0];
const startingPos = positions.find(([_id, p]) => _id === movingMachine);
if (startingPos) {
const isStartingPos =
snapped.x === startingPos[1].position[0] &&
snapped.z === startingPos[1].position[1];
// If Intersect any other machine and not the one being moved
if (!isStartingPos && intersects) {
return;
}
} else {
if (intersects) {
return;
}
}
return snapped;
};
const onAddClick = (event: MouseEvent) => { const onAddClick = (event: MouseEvent) => {
setPositionMode("grid"); setPositionMode("grid");
setWorldMode("create"); ctx.setWorldMode("create");
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const onMouseMove = (event: MouseEvent) => { const onMouseMove = (event: MouseEvent) => {
if (!(worldMode() === "create" || worldMode() === "move")) return; if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return;
if (!actionBase) return;
console.log("Mouse move in create/move mode"); const actionRepr =
ctx.worldMode() === "create" ? actionBase : actionMachine;
if (!actionRepr) return;
actionBase.visible = true; actionRepr.visible = true;
(actionBase.material as THREE.MeshPhongMaterial).emissive.set( // (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE, // worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
); // );
// Calculate mouse position in normalized device coordinates // Calculate mouse position in normalized device coordinates
// (-1 to +1) for both components // (-1 to +1) for both components
@@ -611,41 +723,45 @@ export function CubeScene(props: {
if (intersects.length > 0) { if (intersects.length > 0) {
const point = intersects[0].point; const point = intersects[0].point;
// Snap to grid const snapped = snapToGrid(point);
const snapped = new THREE.Vector3( if (!snapped) return;
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
0,
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 ( if (
Math.abs(actionBase.position.x - snapped.x) > 0.01 || Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
Math.abs(actionBase.position.z - snapped.z) > 0.01 Math.abs(actionRepr.position.z - snapped.z) > 0.01
) { ) {
// Only request render if the position actually changed // Only request render if the position actually changed
actionBase.position.set(snapped.x, 0, snapped.z); actionRepr.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
renderLoop.requestRender(); renderLoop.requestRender();
} }
} }
}; };
const handleMenuSelect = (mode: "move") => { const handleMenuSelect = (mode: "move") => {
setWorldMode(mode); ctx.setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) }); setHighlightGroups({ move: new Set(menuIntersection()) });
console.log("Menu selected, new World mode", worldMode());
// Find the position of the first selected machine
// Set the actionMachine position to that
const firstId = menuIntersection()[0];
if (firstId) {
const machine = machineManager.machines.get(firstId);
if (machine && actionMachine) {
actionMachine.position.set(
machine.group.position.x,
0,
machine.group.position.z,
);
setCursorPosition([machine.group.position.x, machine.group.position.z]);
}
}
}; };
createEffect(
on(ctx.worldMode, (mode) => {
console.log("World mode changed to", mode);
}),
);
const machinesQuery = useMachinesQuery(props.clanURI); const machinesQuery = useMachinesQuery(props.clanURI);
return ( return (
@@ -664,10 +780,10 @@ export function CubeScene(props: {
<div <div
class={cx( class={cx(
"cubes-scene-container", "cubes-scene-container",
worldMode() === "default" && "cursor-no-drop", ctx.worldMode() === "default" && "cursor-no-drop",
worldMode() === "select" && "cursor-pointer", ctx.worldMode() === "select" && "cursor-pointer",
worldMode() === "service" && "cursor-pointer", ctx.worldMode() === "service" && "cursor-pointer",
worldMode() === "create" && "cursor-cell", ctx.worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing", isDragging() && "!cursor-grabbing",
)} )}
ref={(el) => (container = el)} ref={(el) => (container = el)}
@@ -681,24 +797,25 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => setWorldMode("select")} onClick={() => ctx.setWorldMode("select")}
selected={worldMode() === "select"} selected={ctx.worldMode() === "select"}
/> />
<ToolbarButton <ToolbarButton
description="Create new machine" description="Create new machine"
name="new-machine" name="new-machine"
icon="NewMachine" icon="NewMachine"
onClick={onAddClick} onClick={onAddClick}
selected={worldMode() === "create"} selected={ctx.worldMode() === "create"}
/> />
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<ToolbarButton <ToolbarButton
description="Add new Service" description="Add new Service"
name="modules" name="modules"
icon="Services" icon="Services"
selected={worldMode() === "service"} selected={ctx.worldMode() === "service"}
onClick={() => { onClick={() => {
setWorldMode("service"); ctx.navigateToRoot();
ctx.setWorldMode("service");
}} }}
/> />
<ToolbarButton <ToolbarButton

View File

@@ -6,3 +6,35 @@ export const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>
}, },
{} as Pick<T, K>, {} as Pick<T, K>,
); );
export const removeEmptyStrings = <T>(obj: T): T => {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === "string") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => removeEmptyStrings(item)) as T;
}
if (typeof obj === "object") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const key in obj) {
// eslint-disable-next-line no-prototype-builtins
if (obj.hasOwnProperty(key)) {
const value = obj[key];
if (value !== "") {
result[key] = removeEmptyStrings(value);
}
}
}
return result;
}
return obj;
};

View File

@@ -26,13 +26,19 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
const resultData: Partial<ResultDataMap> = { const resultData: Partial<ResultDataMap> = {
list_machines: { list_machines: {
pandora: { pandora: {
name: "pandora", data: {
name: "pandora",
},
}, },
enceladus: { enceladus: {
name: "enceladus", data: {
name: "enceladus",
},
}, },
dione: { dione: {
name: "dione", data: {
name: "dione",
},
}, },
}, },
}; };

View File

@@ -41,7 +41,7 @@ export interface AddMachineProps {
export interface AddMachineStoreType { export interface AddMachineStoreType {
general: GeneralForm; general: GeneralForm;
deploy: { deploy: {
targetHost: string; targetHost?: string;
}; };
tags: { tags: {
tags: string[]; tags: string[];
@@ -111,10 +111,7 @@ export const AddMachine = (props: AddMachineProps) => {
return defaultClass; return defaultClass;
} }
switch (currentStep.id) { return defaultClass;
default:
return defaultClass;
}
}; };
return ( return (

View File

@@ -22,7 +22,7 @@ export const StepProgress = (props: StepProgressProps) => {
when={store.error} when={store.error}
fallback={ fallback={
<> <>
<Loader class="size-8" /> <Loader size="l" />
<Typography hierarchy="body" size="s" weight="medium" family="mono"> <Typography hierarchy="body" size="s" weight="medium" family="mono">
{store.general?.name} is being created {store.general?.name} is being created
</Typography> </Typography>

View File

@@ -1,7 +1,12 @@
import { BackButton, StepLayout } from "@/src/workflows/Steps"; import { BackButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot"; import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper"; import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid"; import {
createForm,
setValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { import {
AddMachineSteps, AddMachineSteps,
AddMachineStoreType, AddMachineStoreType,
@@ -11,6 +16,7 @@ import { MachineTags } from "@/src/components/Form/MachineTags";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import { useClanURI } from "@/src/hooks/clan"; import { useClanURI } from "@/src/hooks/clan";
import { removeEmptyStrings } from "@/src/util";
const TagsSchema = v.object({ const TagsSchema = v.object({
tags: v.array(v.string()), tags: v.array(v.string()),
@@ -36,16 +42,20 @@ export const StepTags = (props: { onDone: () => void }) => {
...values, ...values,
})); }));
const machine = removeEmptyStrings({
...store.general,
...store.tags,
deploy: store.deploy,
});
console.log("machine", machine);
const call = apiClient.fetch("create_machine", { const call = apiClient.fetch("create_machine", {
opts: { opts: {
clan_dir: { clan_dir: {
identifier: clanURI, identifier: clanURI,
}, },
machine: { machine,
...store.general,
...store.tags,
deploy: store.deploy,
},
}, },
}); });
@@ -78,9 +88,12 @@ export const StepTags = (props: { onDone: () => void }) => {
{...field} {...field}
required required
orientation="horizontal" orientation="horizontal"
defaultValue={field.value} defaultValue={field.value || []}
defaultOptions={[]} defaultOptions={[]}
input={input} onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
/> />
)} )}
</Field> </Field>

View File

@@ -75,7 +75,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{ {
name: "gritty.foo", name: "gritty.foo",
description: "Name of the gritty", description: "Name of the gritty",
prompt_type: "line", prompt_type: "hidden",
display: { display: {
helperText: null, helperText: null,
label: "(2) Password", label: "(2) Password",
@@ -113,7 +113,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{ {
name: "gritty.foo", name: "gritty.foo",
description: "Name of the gritty", description: "Name of the gritty",
prompt_type: "line", prompt_type: "hidden",
display: { display: {
helperText: null, helperText: null,
label: "(5) Password", label: "(5) Password",

View File

@@ -51,12 +51,13 @@ export interface InstallStoreType {
progress: ApiCall<"run_machine_flash">; progress: ApiCall<"run_machine_flash">;
}; };
install: { install: {
targetHost: string; targetHost?: string;
port?: string; port?: string;
password?: string;
machineName: string; machineName: string;
mainDisk: string; mainDisk?: string;
// ...TODO Vars // ...TODO Vars
progress: ApiCall<"run_machine_install">; progress: ApiCall<"run_machine_install" | "run_machine_update">;
promptValues: PromptValues; promptValues: PromptValues;
prepareStep: "disk" | "generators" | "install"; prepareStep: "disk" | "generators" | "install";
}; };
@@ -106,22 +107,23 @@ export const InstallModal = (props: InstallModalProps) => {
} }
}; };
const onClose = async () => {
props.onClose?.();
};
return ( return (
<StepperProvider stepper={stepper}> <StepperProvider stepper={stepper}>
<Modal <Modal
class={cx("w-screen", sizeClasses())} class={cx("w-screen", sizeClasses())}
title="Install machine" title="Install machine"
onClose={() => { onClose={onClose}
console.log("Install modal closed");
props.onClose?.();
}}
open={props.open} open={props.open}
// @ts-expect-error some steps might not have // @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined} metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have // @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash} disablePadding={stepper.currentStep()?.isSplash}
> >
<InstallStepper onDone={() => props.onClose} /> <InstallStepper onDone={onClose} />
</Modal> </Modal>
</StepperProvider> </StepperProvider>
); );

View File

@@ -0,0 +1,198 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
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";
import { UpdateModal } from "./UpdateMachine";
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> = {
get_generators: [
{
name: "funny.gritty",
prompts: [
{
name: "gritty.name",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(1) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(2) Password",
group: "Root",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(3) Gritty",
group: "Root",
required: true,
},
},
],
},
{
name: "funny.dodo",
prompts: [
{
name: "gritty.name",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(4) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(5) Password",
group: "Lonely",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(6) Batty",
group: "Root",
required: true,
},
},
],
},
],
run_generators: null,
run_machine_update: null,
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
const status = name === "run_machine_update" ? "error" : "success";
resolve({
op_key: "1",
status: status,
errors: [
{
message: "Mock error message",
description:
"This is a more detailed description of the mock error.",
},
],
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof UpdateModal> = {
title: "workflows/update",
component: UpdateModal,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div class="w-[600px]">
<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 UpdateModal>;
export const Init: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
},
};
export const Address: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
initialStep: "update:address",
},
};
export const UpdateProgress: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
initialStep: "update:progress",
},
};

View File

@@ -0,0 +1,304 @@
import { Modal } from "@/src/components/Modal/Modal";
import {
createStepper,
getStepStore,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import { createSignal, Show } from "solid-js";
import { Dynamic } from "solid-js/web";
import { ConfigureAddress, ConfigureData } from "./steps/installSteps";
import cx from "classnames";
import { InstallStoreType } from "./InstallMachine";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/Icon/Icon";
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
import { useApiClient } from "@/src/hooks/ApiClient";
import { useClanURI } from "@/src/hooks/clan";
import { AlertProps } from "@/src/components/Alert/Alert";
// TODO: Deduplicate
interface UpdateStepperProps {
onDone: () => void;
}
const UpdateStepper = (props: UpdateStepperProps) => {
const stepSignal = useStepper<UpdateSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const [alert, setAlert] = createSignal<AlertProps>();
const clanURI = useClanURI();
const client = useApiClient();
const handleUpdate = async () => {
console.log("Starting update for", store.install.machineName);
if (!store.install.targetHost) {
console.error("No target host specified, API requires it");
return;
}
const port = store.install.port
? parseInt(store.install.port, 10)
: undefined;
const call = client.fetch("run_machine_update", {
machine: {
flake: { identifier: clanURI },
name: store.install.machineName,
},
build_host: null,
target_host: {
address: store.install.targetHost,
port,
password: store.install.password,
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
},
});
// For cancel
set("install", "progress", call);
const result = await call.result;
if (result.status === "error") {
console.error("Update failed", result.errors);
setAlert(() => ({
type: "error",
title: "Update failed",
description: result.errors[0].message,
}));
stepSignal.previous();
return;
}
if (result.status === "success") {
stepSignal.next();
return;
}
};
return (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
next="update"
stepFinished={handleUpdate}
alert={alert()}
/>
);
};
export interface UpdateModalProps {
machineName: string;
open: boolean;
initialStep?: UpdateSteps[number]["id"];
mount?: Node;
onClose?: () => void;
}
export const UpdateHeader = (props: { machineName: string }) => {
return (
<Typography hierarchy="label" size="default">
Update: {props.machineName}
</Typography>
);
};
type UpdateTopic = [
"generators",
"upload-secrets",
"nixos-anywhere",
"formatting",
"rebooting",
"installing",
][number];
const UpdateProgress = () => {
const stepSignal = useStepper<UpdateSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
const handleCancel = async () => {
const progress = store.install.progress;
if (progress) {
await progress.cancel();
}
stepSignal.previous();
};
const updateState =
useNotifyOrigin<ProcessMessage<unknown, UpdateTopic>>("run_machine_update");
return (
<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-2 fg-inv-1">
<Typography
hierarchy="title"
size="default"
weight="bold"
color="inherit"
>
Machine is being updated
</Typography>
<LoadingBar />
<Typography
hierarchy="label"
size="default"
class=""
color="secondary"
inverted
>
Update {updateState()?.topic}...
</Typography>
<Button
hierarchy="primary"
class="mt-3 w-fit"
size="s"
onClick={handleCancel}
>
Cancel
</Button>
</div>
</div>
);
};
interface UpdateDoneProps {
onDone: () => void;
}
const UpdateDone = (props: UpdateDoneProps) => {
const stepSignal = useStepper<UpdateSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
return (
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
<div class="rounded-full bg-semantic-success-4">
<Icon icon="Checkmark" class="size-9" />
</div>
<Typography
hierarchy="title"
size="default"
weight="bold"
color="inherit"
>
Machine update finished!
</Typography>
<div class="mt-3 flex w-full justify-center">
<Button
hierarchy="primary"
endIcon="Close"
size="s"
onClick={() => props.onDone()}
>
Close
</Button>
</div>
</div>
</div>
);
};
const steps = [
{
id: "update:data",
title: UpdateHeader,
content: ConfigureData,
},
{
id: "update:address",
title: UpdateHeader,
content: ConfigureAddress,
},
{
id: "update:progress",
content: UpdateProgress,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
{
id: "update:done",
content: UpdateDone,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
] as const;
export type UpdateSteps = typeof steps;
export type PromptValues = Record<string, Record<string, string>>;
export const UpdateModal = (props: UpdateModalProps) => {
const stepper = createStepper(
{
steps,
},
{
initialStep: props.initialStep || "update:data",
initialStoreData: {
install: { machineName: props.machineName },
} as Partial<InstallStoreType>,
},
);
const MetaHeader = () => {
// @ts-expect-error some steps might not provide a title
const HeaderComponent = () => stepper.currentStep()?.title;
return (
<Show when={HeaderComponent()}>
{(C) => <Dynamic component={C()} machineName={props.machineName} />}
</Show>
);
};
const [store, set] = getStepStore<InstallStoreType>(stepper);
set("install", { machineName: props.machineName });
// allows each step to adjust the size of the modal
const sizeClasses = () => {
const defaultClass = "max-w-3xl h-[30rem]";
const currentStep = stepper.currentStep();
if (!currentStep) {
return defaultClass;
}
switch (currentStep.id) {
case "update:progress":
case "update:done":
return currentStep.class;
default:
return defaultClass;
}
};
const onClose = async () => {
props.onClose?.();
};
return (
<StepperProvider stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Update machine"
onClose={onClose}
open={props.open}
// @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
<UpdateStepper onDone={onClose} />
</Modal>
</StepperProvider>
);
};

View File

@@ -17,7 +17,7 @@ import {
PromptValues, PromptValues,
} from "../InstallMachine"; } from "../InstallMachine";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { Alert } from "@/src/components/Alert/Alert"; import { Alert, AlertProps } from "@/src/components/Alert/Alert";
import { createSignal, For, Match, Show, Switch } from "solid-js"; import { createSignal, For, Match, Show, Switch } from "solid-js";
import { Divider } from "@/src/components/Divider/Divider"; import { Divider } from "@/src/components/Divider/Divider";
import { Orienter } from "@/src/components/Form/Orienter"; import { Orienter } from "@/src/components/Form/Orienter";
@@ -34,6 +34,8 @@ import {
import { useClanURI } from "@/src/hooks/clan"; import { useClanURI } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify"; import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
import { Loader } from "@/src/components/Loader/Loader";
import { Button as KButton } from "@kobalte/core/button";
export const InstallHeader = (props: { machineName: string }) => { export const InstallHeader = (props: { machineName: string }) => {
return ( return (
@@ -54,11 +56,16 @@ const ConfigureAdressSchema = v.object({
v.transform((val) => (val === "" ? undefined : val)), v.transform((val) => (val === "" ? undefined : val)),
), ),
), ),
password: v.optional(v.string()),
}); });
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>; type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
const ConfigureAddress = () => { export const ConfigureAddress = (props: {
next?: string;
stepFinished: () => void;
alert?: AlertProps;
}) => {
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal); const [store, set] = getStepStore<InstallStoreType>(stepSignal);
@@ -84,10 +91,11 @@ const ConfigureAddress = () => {
...s, ...s,
targetHost: values.targetHost, targetHost: values.targetHost,
port: values.port, port: values.port,
password: values.password,
})); }));
// Here you would typically trigger the ISO creation process
stepSignal.next(); stepSignal.next();
props.stepFinished?.();
}; };
const tryReachable = async () => { const tryReachable = async () => {
@@ -98,12 +106,14 @@ const ConfigureAddress = () => {
const portValue = getValue(formStore, "port"); const portValue = getValue(formStore, "port");
const port = portValue ? parseInt(portValue, 10) : undefined; const port = portValue ? parseInt(portValue, 10) : undefined;
const password = getValue(formStore, "password") || undefined;
setLoading(true); setLoading(true);
const call = client.fetch("check_machine_ssh_login", { const call = client.fetch("check_machine_ssh_login", {
remote: { remote: {
address, address,
...(port && { port }), ...(port && { port }),
password: password,
ssh_options: { ssh_options: {
StrictHostKeyChecking: "no", StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null", UserKnownHostsFile: "/dev/null",
@@ -124,13 +134,14 @@ const ConfigureAddress = () => {
<StepLayout <StepLayout
body={ body={
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show when={props.alert}>{(alert) => <Alert {...alert()} />}</Show>
<Fieldset> <Fieldset>
<Field name="targetHost"> <Field name="targetHost">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
{...field} {...field}
label="IP Address" label="IP Address"
description="Hostname of the installation target" description="Hostname of the machine"
value={field.value} value={field.value}
required required
orientation="horizontal" orientation="horizontal"
@@ -163,6 +174,24 @@ const ConfigureAddress = () => {
/> />
)} )}
</Field> </Field>
<Field name="password">
{(field, props) => (
<TextInput
{...field}
label="Password"
description="SSH password (optional)"
value={field.value}
orientation="horizontal"
validationState={
getError(formStore, "port") ? "invalid" : "valid"
}
input={{
...props,
type: "password",
}}
/>
)}
</Field>
</Fieldset> </Fieldset>
</div> </div>
} }
@@ -175,7 +204,9 @@ const ConfigureAddress = () => {
!isReachable() || !isReachable() ||
isReachable() !== getValue(formStore, "targetHost") isReachable() !== getValue(formStore, "targetHost")
} }
fallback={<NextButton type="submit">Next</NextButton>} fallback={
<NextButton type="submit">{props.next || "next"}</NextButton>
}
> >
<Button <Button
endIcon="ArrowRight" endIcon="ArrowRight"
@@ -212,6 +243,14 @@ const CheckHardware = () => {
createSignal(false); createSignal(false);
const handleUpdateSummary = async () => { const handleUpdateSummary = async () => {
if (!store.install.targetHost) {
console.error(
"Target host not set, this is required for updating hardware report",
);
setUpdatingHardwareReport(false);
return;
}
setUpdatingHardwareReport(true); setUpdatingHardwareReport(true);
const port = store.install.port const port = store.install.port
@@ -223,7 +262,8 @@ const CheckHardware = () => {
const call = client.fetch("run_machine_hardware_info", { const call = client.fetch("run_machine_hardware_info", {
target_host: { target_host: {
address: store.install.targetHost, address: store.install.targetHost,
...(port && { port }), port,
password: store.install.password,
ssh_options: { ssh_options: {
StrictHostKeyChecking: "no", StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null", UserKnownHostsFile: "/dev/null",
@@ -386,7 +426,7 @@ const ConfigureDisk = () => {
); );
}; };
const ConfigureData = () => { export const ConfigureData = () => {
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal); const [store, get] = getStepStore<InstallStoreType>(stepSignal);
@@ -398,7 +438,22 @@ const ConfigureData = () => {
return ( return (
<> <>
<Show when={generatorsQuery.isLoading}> <Show when={generatorsQuery.isLoading}>
Checking credentials & data... <div class="relative flex w-full flex-col items-center justify-end ">
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 pt-4">
<Loader />
<Typography
hierarchy="title"
size="default"
weight="bold"
color="inherit"
>
Credentials & Data
</Typography>
<Typography hierarchy="label" size="default" color="secondary">
Loading Machine Generators ...
</Typography>
</div>
</div>
</Show> </Show>
<Show when={generatorsQuery.data}> <Show when={generatorsQuery.data}>
{(generators) => <PromptsFields generators={generators()} />} {(generators) => <PromptsFields generators={generators()} />}
@@ -500,7 +555,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
}; };
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit} class="h-full">
<StepLayout <StepLayout
body={ body={
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -512,35 +567,64 @@ const PromptsFields = (props: PromptsFieldsProps) => {
<Field <Field
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`} name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
> >
{(f, props) => ( {(f, props) => {
<TextInput const defaultInputType =
{...f} fieldInfo.prompt.prompt_type.includes("hidden")
label={ ? "password"
fieldInfo.prompt.display?.label || : "text";
fieldInfo.prompt.name
} const [inputType, setInputType] =
description={fieldInfo.prompt.description} createSignal(defaultInputType);
value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required} return (
orientation="horizontal" <TextInput
validationState={ {...f}
getError( label={
formStore, fieldInfo.prompt.display?.label ||
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`, fieldInfo.prompt.name
) }
? "invalid" endComponent={(local) => (
: "valid" <Show when={defaultInputType === "password"}>
} <KButton
input={{ onClick={() => {
type: fieldInfo.prompt.prompt_type.includes( setInputType((type) =>
"hidden", type === "password"
) ? "text"
? "password" : "password",
: "text", );
...props, }}
}} >
/> <Icon
)} icon={
inputType() == "password"
? "EyeClose"
: "EyeOpen"
}
color="quaternary"
inverted={local.inverted}
/>
</KButton>
</Show>
)}
description={fieldInfo.prompt.description}
value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required}
orientation="horizontal"
validationState={
getError(
formStore,
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
)
? "invalid"
: "valid"
}
input={{
type: inputType(),
...props,
}}
/>
);
}}
</Field> </Field>
)} )}
</For> </For>
@@ -560,7 +644,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
); );
}; };
const Display = (props: { value: string; label: string }) => { const Display = (props: { value?: string; label: string }) => {
return ( return (
<> <>
<Typography hierarchy="label" size="xs" color="primary" weight="bold"> <Typography hierarchy="label" size="xs" color="primary" weight="bold">
@@ -583,7 +667,15 @@ const InstallSummary = () => {
const handleInstall = async () => { const handleInstall = async () => {
// Here you would typically trigger the installation process // Here you would typically trigger the installation process
console.log("Installation started"); console.log("Installation started");
if (!store.install.mainDisk) {
console.error("Main disk not set");
return;
}
if (!store.install.targetHost) {
console.error("Target host not set, this is required for installing");
return;
}
stepSignal.setActiveStep("install:progress"); stepSignal.setActiveStep("install:progress");
const setDisk = client.fetch("set_machine_disk_schema", { const setDisk = client.fetch("set_machine_disk_schema", {
@@ -649,7 +741,8 @@ const InstallSummary = () => {
}, },
target_host: { target_host: {
address: store.install.targetHost, address: store.install.targetHost,
...(port && { port }), port,
password: store.install.password,
ssh_options: { ssh_options: {
StrictHostKeyChecking: "no", StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null", UserKnownHostsFile: "/dev/null",
@@ -693,7 +786,7 @@ const InstallSummary = () => {
</Orienter> </Orienter>
<Divider orientation="horizontal" /> <Divider orientation="horizontal" />
<Orienter orientation="horizontal"> <Orienter orientation="horizontal">
<Display label="Main Disk" value={store.install.mainDisk} /> <Display label="Main Disk" value={store.install?.mainDisk} />
</Orienter> </Orienter>
</Fieldset> </Fieldset>
</div> </div>

View File

@@ -0,0 +1,120 @@
import { Search } from "@/src/components/Search/Search";
import { Typography } from "@/src/components/Typography/Typography";
import { buildServicePath, useClanURI } from "@/src/hooks/clan";
import { useServiceInstances, useServiceModules } from "@/src/hooks/queries";
import { useNavigate } from "@solidjs/router";
import { createEffect, createSignal, Show } from "solid-js";
import { Module } from "./models";
import Icon from "@/src/components/Icon/Icon";
import { Combobox } from "@kobalte/core/combobox";
import { useClickOutside } from "@/src/hooks/useClickOutside";
interface FlyoutProps {
onClose: () => void;
}
export const SelectService = (props: FlyoutProps) => {
const clanURI = useClanURI();
const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions(
serviceModulesQuery.data.modules.map((currService) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
label: currService.usage_ref.name,
raw: currService,
})),
);
}
});
const handleChange = (module: Module | null) => {
if (!module) return;
const serviceURL = buildServicePath({
clanURI,
id: module.raw.instance_refs[0] || module.raw.usage_ref.name,
module: {
name: module.raw.usage_ref.name,
input: module.raw.usage_ref.input,
},
});
navigate(serviceURL);
};
const navigate = useNavigate();
let ref: HTMLDivElement;
useClickOutside(
() => ref,
() => {
props.onClose();
},
);
return (
<div
ref={(e) => (ref = e)}
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
>
<div class="w-[30rem]">
<Search<Module>
loading={
serviceModulesQuery.isLoading || serviceInstancesQuery.isLoading
}
height="13rem"
onChange={handleChange}
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.raw.instance_refs.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-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core">
by {item.raw.usage_ref.input}
</Show>
</span>
</Typography>
</div>
</div>
);
}}
/>
</div>
</div>
);
};

View File

@@ -62,20 +62,28 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
}, },
list_machines: { list_machines: {
jon: { jon: {
name: "jon", data: {
tags: ["all", "nixos", "tag1"], name: "jon",
tags: ["all", "nixos", "tag1"],
},
}, },
sara: { sara: {
name: "sara", data: {
tags: ["all", "darwin", "tag2"], name: "sara",
tags: ["all", "darwin", "tag2"],
},
}, },
kyra: { kyra: {
name: "kyra", data: {
tags: ["all", "darwin", "tag2"], name: "kyra",
tags: ["all", "darwin", "tag2"],
},
}, },
leila: { leila: {
name: "leila", data: {
tags: ["all", "darwin", "tag2"], name: "leila",
tags: ["all", "darwin", "tag2"],
},
}, },
}, },
list_tags: { list_tags: {
@@ -152,6 +160,9 @@ export const SelectRoleMembers: Story = {
handleSubmit={(instance) => { handleSubmit={(instance) => {
console.log("Submitted instance:", instance); console.log("Submitted instance:", instance);
}} }}
onClose={() => {
console.log("Closed");
}}
initialStep="select:members" initialStep="select:members"
initialStore={{ initialStore={{
currentRole: "peer", currentRole: "peer",

View File

@@ -4,10 +4,9 @@ import {
StepperProvider, StepperProvider,
useStepper, useStepper,
} from "@/src/hooks/stepper"; } from "@/src/hooks/stepper";
import { useClanURI } from "@/src/hooks/clan"; import { useClanURI, useServiceParams } from "@/src/hooks/clan";
import { import {
MachinesQuery, MachinesQuery,
ServiceModules,
TagsQuery, TagsQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstances, useServiceInstances,
@@ -18,18 +17,15 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
For,
JSX,
Show, Show,
on, on,
onMount, onMount,
For,
} from "solid-js"; } from "solid-js";
import { Search } from "@/src/components/Search/Search";
import Icon from "@/src/components/Icon/Icon"; import Icon from "@/src/components/Icon/Icon";
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { Typography } from "@/src/components/Typography/Typography"; 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 { createForm, FieldValues } from "@modular-forms/solid";
import styles from "./Service.module.css"; import styles from "./Service.module.css";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
@@ -40,152 +36,16 @@ import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
import { useMachineClick } from "@/src/scene/cubes"; import { useMachineClick } from "@/src/scene/cubes";
import { import {
clearAllHighlights, clearAllHighlights,
highlightGroups,
setHighlightGroups, setHighlightGroups,
} from "@/src/scene/highlightStore"; } from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside"; import {
getRoleMembers,
type ModuleItem = ServiceModules["modules"][number]; RoleType,
ServiceStoreType,
interface Module { SubmitServiceHandler,
value: string; } from "./models";
label: string; import { TagSelect } from "@/src/components/Search/TagSelect";
raw: ModuleItem; import { Tag } from "@/src/components/Tag/Tag";
}
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.modules.map((currService) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
label: currService.usage_ref.name,
raw: currService,
})),
);
}
});
const [store, set] = getStepStore<ServiceStoreType>(stepper);
return (
<Search<Module>
loading={serviceModulesQuery.isLoading}
height="13rem"
onChange={(module) => {
if (!module) return;
set("module", {
name: module.raw.usage_ref.name,
input: module.raw.usage_ref.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.raw.instance_refs.length === 0) {
set("action", "create");
} else {
if (!serviceInstancesQuery.data) return;
if (!machinesQuery.data) return;
set("action", "update");
const instanceName = module.raw.instance_refs[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.raw.instance_refs.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-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core">
by {item.raw.usage_ref.input}
</Show>
</span>
</Typography>
</div>
</div>
);
}}
/>
);
};
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
createMemo<TagType[]>(() => { createMemo<TagType[]>(() => {
@@ -206,7 +66,7 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
label: tag, label: tag,
value: "t_" + tag, value: "t_" + tag,
members: Object.entries(machines) members: Object.entries(machines)
.filter(([_, v]) => v.tags?.includes(tag)) .filter(([_, v]) => v.data.tags?.includes(tag))
.map(([k]) => k), .map(([k]) => k),
})); }));
@@ -215,22 +75,86 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
); );
}); });
const sanitizeModuleInput = (
input: string | undefined,
core_input_name: string,
) => {
if (!input) return null;
if (input === core_input_name) return null;
return input;
};
interface RolesForm extends FieldValues { interface RolesForm extends FieldValues {
roles: Record<string, string[]>; roles: Record<string, string[]>;
instanceName: string; instanceName: string;
} }
const ConfigureService = () => { const ConfigureService = () => {
const stepper = useStepper<ServiceSteps>(); const stepper = useStepper<ServiceSteps>();
const clanURI = useClanURI();
const machinesQuery = useMachinesQuery(clanURI);
const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const routerProps = useServiceParams();
const [store, set] = getStepStore<ServiceStoreType>(stepper); const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RolesForm>({ const [formStore, { Form, Field }] = createForm<RolesForm>({
initialValues: { initialValues: {
// Default to the module name, until we support multiple instances // Default to the module name, until we support multiple instances
instanceName: store.module.name, instanceName: routerProps.id,
}, },
}); });
const machinesQuery = useMachinesQuery(useClanURI()); const selectedModule = createMemo(() => {
if (!serviceModulesQuery.data) return undefined;
return serviceModulesQuery.data.modules.find(
(m) =>
m.usage_ref.name === routerProps.name &&
// left side is string | null
// right side is string | undefined
m.usage_ref.input ===
sanitizeModuleInput(
routerProps.input,
serviceModulesQuery.data.core_input_name,
),
);
});
createEffect(
on(
() => [serviceInstancesQuery.data, machinesQuery.data] as const,
([instances, machines]) => {
// Wait for all queries to be ready
if (!instances || !machines) return;
const instance = instances[routerProps.id || routerProps.name];
set("roles", {});
if (!instance) {
set("action", "create");
return;
}
for (const role of Object.keys(instance.roles || {})) {
// Get Role members
const roleMembers = getRoleMembers(instance, machines, role);
set("roles", role, roleMembers);
}
set("action", "update");
},
),
);
const currentModuleRoles = createMemo(() => {
const module = selectedModule();
if (!module) return [];
return Object.keys(module.info.roles).map((role) => ({
role,
members: store.roles?.[role] || [],
}));
});
const tagsQuery = useTags(useClanURI()); const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
@@ -249,13 +173,15 @@ const ConfigureService = () => {
}, },
]), ]),
); );
store.handleSubmit( store.handleSubmit(
{ {
name: values.instanceName, name: values.instanceName,
module: { module: {
name: store.module.name, name: routerProps.name,
input: store.module.input, input: sanitizeModuleInput(
routerProps.input,
serviceModulesQuery.data?.core_input_name || "clan-core",
),
}, },
roles, roles,
}, },
@@ -271,7 +197,7 @@ const ConfigureService = () => {
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<Typography hierarchy="body" size="s" weight="medium" inverted> <Typography hierarchy="body" size="s" weight="medium" inverted>
{store.module.name} {routerProps.name}
</Typography> </Typography>
<Field name="instanceName"> <Field name="instanceName">
{(field, input) => ( {(field, input) => (
@@ -294,54 +220,70 @@ const ConfigureService = () => {
ghost ghost
size="s" size="s"
class="ml-auto" class="ml-auto"
onClick={store.close} onClick={() => store.close()}
/> />
</div> </div>
<div class={styles.content}> <div class={styles.content}>
<For each={Object.keys(store.module.raw?.info.roles || {})}> <Show
{(role) => { when={serviceModulesQuery.data && store.roles}
const values = store.roles?.[role] || []; fallback={<div>Loading...</div>}
return ( >
<TagSelect<TagType> <For each={currentModuleRoles()}>
label={role} {(role) => {
renderItem={(item: TagType) => ( return (
<Tag <TagSelect<TagType>
inverted label={role.role}
icon={(tag) => ( renderItem={(item: TagType) => (
<Icon <Tag
icon={item.type === "machine" ? "Machine" : "Tag"} inverted
size="0.5rem" icon={(tag) => (
inverted={tag.inverted} <Icon
/> icon={item.type === "machine" ? "Machine" : "Tag"}
)} size="0.5rem"
> inverted={tag.inverted}
{item.label} />
</Tag> )}
)} >
values={values} {item.label}
options={options()} </Tag>
onClick={() => { )}
set("currentRole", role); values={role.members}
stepper.next(); options={options()}
}} onClick={() => {
/> set("currentRole", role.role);
); stepper.next();
}} }}
</For> />
);
}}
</For>
</Show>
</div> </div>
<div class={cx(styles.footer, styles.backgroundAlt)}> <div class={cx(styles.footer, styles.backgroundAlt)}>
<BackButton ghost hierarchy="primary" class="mr-auto" /> <Button
hierarchy="secondary"
<Button hierarchy="secondary" type="submit"> type="submit"
<Show when={store.action === "create"}>Add Service</Show> loading={!serviceInstancesQuery.data}
<Show when={store.action === "update"}>Save Changes</Show> >
<Show when={serviceInstancesQuery.data}>
{(d) => (
<>
<Show
when={Object.keys(d()).includes(routerProps.id)}
fallback={"Add Service"}
>
Save Changes
</Show>
</>
)}
</Show>
</Button> </Button>
</div> </div>
</Form> </Form>
); );
}; };
type TagType = export type TagType =
| { | {
value: string; value: string;
label: string; label: string;
@@ -362,31 +304,36 @@ const ConfigureRole = () => {
store.roles?.[store.currentRole || ""] || [], store.roles?.[store.currentRole || ""] || [],
); );
const clanUri = useClanURI();
const machinesQuery = useMachinesQuery(clanUri);
const lastClickedMachine = useMachineClick(); const lastClickedMachine = useMachineClick();
createEffect(() => { createEffect(
console.log("Current role", store.currentRole, members()); on(members, (m) => {
clearAllHighlights(); clearAllHighlights();
setHighlightGroups({ setHighlightGroups({
[store.currentRole as string]: new Set( [store.currentRole as string]: new Set(
members().flatMap((m) => { m.flatMap((m) => {
if (m.type === "machine") return m.label; if (m.type === "machine") return m.label;
return m.members; return m.members;
}), }),
), ),
}); });
}),
);
console.log("now", highlightGroups); onMount(() => {
setHighlightGroups(() => ({}));
}); });
onMount(() => setHighlightGroups(() => ({})));
createEffect( createEffect(
on(lastClickedMachine, (machine) => { on(lastClickedMachine, (machine) => {
// const machine = lastClickedMachine(); // const machine = lastClickedMachine();
const currentMembers = members(); const currentMembers = members();
console.log("Clicked machine", machine, currentMembers);
if (!machine) return; if (!machine) return;
const machineTagName = "m_" + machine; const machineTagName = "m_" + machine;
const existing = currentMembers.find((m) => m.value === machineTagName); const existing = currentMembers.find((m) => m.value === machineTagName);
@@ -403,7 +350,6 @@ const ConfigureRole = () => {
}), }),
); );
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI()); const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
@@ -428,12 +374,7 @@ const ConfigureRole = () => {
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
headerChildren={ headerChildren={
<div class="flex w-full gap-2.5"> <div class="flex w-full gap-2.5">
<BackButton <BackButton ghost size="xs" hierarchy="primary" />
ghost
size="xs"
hierarchy="primary"
// onClick={() => clearAllHighlights()}
/>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="s" size="s"
@@ -505,10 +446,6 @@ const ConfigureRole = () => {
}; };
const steps = [ const steps = [
{
id: "select:service",
content: SelectService,
},
{ {
id: "view:members", id: "view:members",
content: ConfigureService, content: ConfigureService,
@@ -522,79 +459,34 @@ const steps = [
export type ServiceSteps = typeof steps; 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 | null;
};
roles: Record<string, RoleType>;
}
interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export interface ServiceStoreType {
module: {
name: string;
input?: string | null;
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 { interface ServiceWorkflowProps {
initialStep?: ServiceSteps[number]["id"]; initialStep?: ServiceSteps[number]["id"];
initialStore?: Partial<ServiceStoreType>; initialStore?: Partial<ServiceStoreType>;
onClose?: () => void; onClose: () => void;
handleSubmit: SubmitServiceHandler; handleSubmit: SubmitServiceHandler;
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
} }
export const ServiceWorkflow = (props: ServiceWorkflowProps) => { export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
const stepper = createStepper( const stepper = createStepper(
{ steps }, { steps },
{ {
initialStep: props.initialStep || "select:service", initialStep: props.initialStep || "view:members",
initialStoreData: { initialStoreData: {
...props.initialStore, ...props.initialStore,
close: () => props.onClose?.(), close: props.onClose,
handleSubmit: props.handleSubmit, handleSubmit: props.handleSubmit,
} satisfies Partial<ServiceStoreType>, } satisfies Partial<ServiceStoreType>,
}, },
); );
createEffect(() => { createEffect(() => {
if (stepper.currentStep().id !== "select:members") { if (stepper.currentStep().id !== "select:members") {
clearAllHighlights(); clearAllHighlights();
} }
}); });
let ref: HTMLDivElement;
useClickOutside(
() => ref,
() => {
if (stepper.currentStep().id === "select:service") props.onClose?.();
},
);
return ( return (
<div <div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
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}> <StepperProvider stepper={stepper}>
<div class="w-[30rem]">{stepper.currentStep().content()}</div> <div class="w-[30rem]">{stepper.currentStep().content()}</div>
</StepperProvider> </StepperProvider>

View File

@@ -0,0 +1,83 @@
import {
MachinesQuery,
ServiceInstancesQuery,
ServiceModules,
} from "@/src/hooks/queries";
import { TagType } from "./Service";
export interface ServiceStoreType {
roles: Record<string, TagType[]>;
currentRole?: string;
close: () => void;
handleSubmit: SubmitServiceHandler;
action: "create" | "update";
}
// TODO: Ideally we would impot this from a backend model package
export interface InventoryInstance {
name: string;
module: {
name: string;
input?: string | null;
};
roles: Record<string, RoleType>;
}
export interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export type SubmitServiceHandler = (
values: InventoryInstance,
action: "create" | "update",
) => void | Promise<void>;
export type ModuleItem = ServiceModules["modules"][number];
export interface Module {
value: string;
label: string;
raw: ModuleItem;
}
type ValueOf<T> = T[keyof T];
export type Instance = ValueOf<NonNullable<ServiceInstancesQuery["data"]>>;
/**
* Collect all members (machines and tags) for a given role in a service instance
*
* TODO: Make this native feature of the API
*
*/
export function getRoleMembers(
instance: Instance,
all_machines: NonNullable<MachinesQuery["data"]>,
role: string,
) {
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(all_machines)
.filter(([_, m]) => m.data.tags?.includes(t))
.map(([k]) => k),
};
});
console.log("Members for role", role, [...machineTags, ...tagsTags]);
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
a.label.localeCompare(b.label),
);
return roleMembers;
}

View File

@@ -24,19 +24,10 @@ clangStdenv.mkDerivation {
domain = "git.clan.lol"; domain = "git.clan.lol";
owner = "clan"; owner = "clan";
repo = "webview"; repo = "webview";
rev = "ef481aca8e531f6677258ca911c61aaaf71d2214"; rev = "c27041cb50f79c197080a3f4fa2bad4557ef3234";
hash = "sha256-KF9ESpo40z6VXyYsZCLWJAIh0RFe1Zy/Qw4k7cTpoYU="; hash = "sha256-xNkX7O+GFMbv3YnXPrtO6vw+BUqCbVeFd8FjgPKfEG0=";
}; };
# @Mic92: Where is this revision coming from? I can't see it in any of the branches.
# I removed the icon python code for now
# src = pkgs.fetchFromGitHub {
# owner = "clan-lol";
# repo = "webview";
# rev = "7d24f0192765b7e08f2d712fae90c046d08f318e";
# hash = "sha256-yokVI9tFiEEU5M/S2xAeJOghqqiCvTelLo8WLKQZsSY=";
# };
outputs = [ outputs = [
"out" "out"
"dev" "dev"

View File

@@ -103,7 +103,9 @@ def get_machines_for_update(
machines_to_update = list( machines_to_update = list(
filter( filter(
requires_explicit_update, requires_explicit_update,
instantiate_inventory_to_machines(flake, machines_with_tags).values(), instantiate_inventory_to_machines(
flake, {name: m.data for name, m in machines_with_tags.items()}
).values(),
), ),
) )
# all machines that are in the clan but not included in the update list # all machines that are in the clan but not included in the update list
@@ -128,13 +130,13 @@ def get_machines_for_update(
machines_to_update = [] machines_to_update = []
valid_names = validate_machine_names(explicit_names, flake) valid_names = validate_machine_names(explicit_names, flake)
for name in valid_names: for name in valid_names:
inventory_machine = machines_with_tags.get(name) machine = machines_with_tags.get(name)
if not inventory_machine: if not machine:
msg = "This is an internal bug" msg = "This is an internal bug"
raise ClanError(msg) raise ClanError(msg)
machines_to_update.append( machines_to_update.append(
Machine.from_inventory(name, flake, inventory_machine), Machine.from_inventory(name, flake, machine.data),
) )
return machines_to_update return machines_to_update

View File

@@ -0,0 +1,39 @@
{
python3,
fetchFromGitHub,
}:
rec {
asyncore-wsgi = python3.pkgs.buildPythonPackage rec {
pname = "asyncore-wsgi";
version = "0.0.11";
src = fetchFromGitHub {
owner = "romanvm";
repo = "asyncore-wsgi";
rev = "${version}";
sha256 = "sha256-06rWCC8qZb9H9qPUDQpzASKOY4VX+Y+Bm9a5e71Hqhc=";
};
pyproject = true;
buildInputs = [
python3.pkgs.setuptools
];
};
web-pdb = python3.pkgs.buildPythonPackage rec {
pname = "web-pdb";
version = "1.6.3";
src = fetchFromGitHub {
owner = "romanvm";
repo = "python-web-pdb";
rev = "${version}";
sha256 = "sha256-VG0mHbogx0n1f38h9VVxFQgjvghipAf1rb43/Bwb/8I=";
};
pyproject = true;
buildInputs = [
python3.pkgs.setuptools
];
propagatedBuildInputs = [
python3.pkgs.bottle
asyncore-wsgi
];
};
}

View File

@@ -431,20 +431,22 @@ def test_generated_shared_secret_sops(
generator_m1 = Generator( generator_m1 = Generator(
"my_shared_generator", "my_shared_generator",
share=True, share=True,
machine="machine1",
_flake=machine1.flake, _flake=machine1.flake,
) )
generator_m2 = Generator( generator_m2 = Generator(
"my_shared_generator", "my_shared_generator",
share=True, share=True,
machine="machine2",
_flake=machine2.flake, _flake=machine2.flake,
) )
assert m1_sops_store.exists(generator_m1, "my_shared_secret") assert m1_sops_store.exists(generator_m1, "my_shared_secret")
assert m2_sops_store.exists(generator_m2, "my_shared_secret") assert m2_sops_store.exists(generator_m2, "my_shared_secret")
assert m1_sops_store.machine_has_access(generator_m1, "my_shared_secret") assert m1_sops_store.machine_has_access(
assert m2_sops_store.machine_has_access(generator_m2, "my_shared_secret") generator_m1, "my_shared_secret", "machine1"
)
assert m2_sops_store.machine_has_access(
generator_m2, "my_shared_secret", "machine2"
)
@pytest.mark.with_core @pytest.mark.with_core
@@ -499,6 +501,7 @@ def test_generate_secret_var_password_store(
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
assert check_vars(machine.name, machine.flake) assert check_vars(machine.name, machine.flake)
store = password_store.SecretStore(flake=flake_obj) store = password_store.SecretStore(flake=flake_obj)
store.init_pass_command(machine="my_machine")
my_generator = Generator( my_generator = Generator(
"my_generator", "my_generator",
share=False, share=False,
@@ -744,6 +747,74 @@ def test_shared_vars_must_never_depend_on_machine_specific_vars(
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
@pytest.mark.with_core
def test_shared_vars_regeneration(
monkeypatch: pytest.MonkeyPatch,
flake_with_sops: ClanFlake,
) -> None:
"""Ensure that is a shared generator gets generated on one machine, dependents of that
shared generator on other machines get re-generated as well.
"""
flake = flake_with_sops
machine1_config = flake.machines["machine1"]
machine1_config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
shared_generator = machine1_config["clan"]["core"]["vars"]["generators"][
"shared_generator"
]
shared_generator["share"] = True
shared_generator["files"]["my_value"]["secret"] = False
shared_generator["script"] = 'echo "$RANDOM" > "$out"/my_value'
child_generator = machine1_config["clan"]["core"]["vars"]["generators"][
"child_generator"
]
child_generator["share"] = False
child_generator["files"]["my_value"]["secret"] = False
child_generator["dependencies"] = ["shared_generator"]
child_generator["script"] = 'cat "$in"/shared_generator/my_value > "$out"/my_value'
# machine 2 is equivalent to machine 1
flake.machines["machine2"] = machine1_config
flake.refresh()
monkeypatch.chdir(flake.path)
machine1 = Machine(name="machine1", flake=Flake(str(flake.path)))
machine2 = Machine(name="machine2", flake=Flake(str(flake.path)))
in_repo_store_1 = in_repo.FactStore(machine1.flake)
in_repo_store_2 = in_repo.FactStore(machine2.flake)
# Create generators with machine context for testing
child_gen_m1 = Generator(
"child_generator", share=False, machine="machine1", _flake=machine1.flake
)
child_gen_m2 = Generator(
"child_generator", share=False, machine="machine2", _flake=machine2.flake
)
# generate for machine 1
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
# generate for machine 2
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
# child value should be the same on both machines
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
child_gen_m2, "my_value"
), "Child values should be the same after initial generation"
# regenerate on all machines
cli.run(
["vars", "generate", "--flake", str(flake.path), "--regenerate"],
)
# ensure child value after --regenerate is the same on both machines
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
child_gen_m2, "my_value"
), "Child values should be the same after regenerating all machines"
# regenerate for machine 1
cli.run(
["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"]
)
# ensure child value after --regenerate is the same on both machines
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
child_gen_m2, "my_value"
), "Child values should be the same after regenerating machine1"
@pytest.mark.with_core @pytest.mark.with_core
def test_multi_machine_shared_vars( def test_multi_machine_shared_vars(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -816,8 +887,8 @@ def test_multi_machine_shared_vars(
assert new_value_1 != m1_value assert new_value_1 != m1_value
# ensure that both machines still have access to the same secret # ensure that both machines still have access to the same secret
assert new_secret_1 == new_secret_2 assert new_secret_1 == new_secret_2
assert sops_store_1.machine_has_access(generator_m1, "my_secret") assert sops_store_1.machine_has_access(generator_m1, "my_secret", "machine1")
assert sops_store_2.machine_has_access(generator_m2, "my_secret") assert sops_store_2.machine_has_access(generator_m2, "my_secret", "machine2")
@pytest.mark.with_core @pytest.mark.with_core
@@ -862,7 +933,7 @@ def test_api_set_prompts(
machine = Machine(name="my_machine", flake=Flake(str(flake.path))) machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
generators = get_generators( generators = get_generators(
machine=machine, machines=[machine],
full_closure=True, full_closure=True,
include_previous_values=True, include_previous_values=True,
) )

View File

@@ -42,11 +42,7 @@ class StoreBase(ABC):
"""Get machine name from generator, asserting it's not None for now.""" """Get machine name from generator, asserting it's not None for now."""
if generator.machine is None: if generator.machine is None:
if generator.share: if generator.share:
# Shared generators don't need a machine for most operations return "__shared"
# but some operations (like SOPS key management) might still need one
# This is a temporary workaround - we should handle this better
msg = f"Shared generator '{generator.name}' requires a machine context for this operation"
raise ClanError(msg)
msg = f"Generator '{generator.name}' has no machine associated" msg = f"Generator '{generator.name}' has no machine associated"
raise ClanError(msg) raise ClanError(msg)
return generator.machine return generator.machine
@@ -62,6 +58,7 @@ class StoreBase(ABC):
generator: "Generator", generator: "Generator",
var: "Var", var: "Var",
value: bytes, value: bytes,
machine: str,
) -> Path | None: ) -> Path | None:
"""Override this method to implement the actual creation of the file""" """Override this method to implement the actual creation of the file"""
@@ -140,16 +137,20 @@ class StoreBase(ABC):
generator: "Generator", generator: "Generator",
var: "Var", var: "Var",
value: bytes, value: bytes,
machine: str,
is_migration: bool = False, is_migration: bool = False,
) -> list[Path]: ) -> list[Path]:
changed_files: list[Path] = [] changed_files: list[Path] = []
# if generator was switched from shared to per-machine or vice versa, # if generator was switched from shared to per-machine or vice versa,
# remove the old var first # remove the old var first
if self.exists( prev_generator = dataclasses.replace(
gen := dataclasses.replace(generator, share=not generator.share), var.name generator,
): share=not generator.share,
changed_files += self.delete(gen, var.name) machine=machine if generator.share else None,
)
if self.exists(prev_generator, var.name):
changed_files += self.delete(prev_generator, var.name)
if self.exists(generator, var.name): if self.exists(generator, var.name):
if self.is_secret_store: if self.is_secret_store:
@@ -161,7 +162,7 @@ class StoreBase(ABC):
else: else:
old_val = None old_val = None
old_val_str = "<not set>" old_val_str = "<not set>"
new_file = self._set(generator, var, value) new_file = self._set(generator, var, value, machine)
action_str = "Migrated" if is_migration else "Updated" action_str = "Migrated" if is_migration else "Updated"
log_info: Callable log_info: Callable
if generator.machine is None: if generator.machine is None:
@@ -169,8 +170,8 @@ class StoreBase(ABC):
else: else:
from clan_lib.machines.machines import Machine # noqa: PLC0415 from clan_lib.machines.machines import Machine # noqa: PLC0415
machine = Machine(name=generator.machine, flake=self.flake) machine_obj = Machine(name=generator.machine, flake=self.flake)
log_info = machine.info log_info = machine_obj.info
if self.is_secret_store: if self.is_secret_store:
log.info(f"{action_str} secret var {generator.name}/{var.name}\n") log.info(f"{action_str} secret var {generator.name}/{var.name}\n")
elif value != old_val: elif value != old_val:

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