Compare commits

...

99 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
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
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
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
92 changed files with 3129 additions and 1022 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

@@ -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

@@ -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

@@ -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

@@ -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,6 +1,13 @@
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";
@@ -14,16 +21,15 @@ 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,37 +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 =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
>
<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>
);
};
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[]
@@ -88,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") {
@@ -96,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";
@@ -126,35 +179,16 @@ export const MachineTags = (props: MachineTagsProps) => {
class={cx("form-field", styles.machineTags, props.orientation)} class={cx("form-field", styles.machineTags, props.orientation)}
{...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(props.inverted || false)} 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()}>
@@ -164,7 +198,12 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props} {...props}
/> />
<Combobox.HiddenSelect {...props.input} multiple /> <Combobox.HiddenSelect
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.Control<MachineTag> <Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)} class={cx(styles.control, props.orientation)}
@@ -187,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,
);
})
}
/> />
) )
} }
@@ -220,7 +265,6 @@ export const MachineTags = (props: MachineTagsProps) => {
)} )}
</Combobox.Control> </Combobox.Control>
</Orienter> </Orienter>
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content <Combobox.Content
class={cx(styles.comboboxContent, { class={cx(styles.comboboxContent, {

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

@@ -5,11 +5,12 @@ import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { For, Show } 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;
@@ -486,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: {
@@ -506,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

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:

View File

@@ -2,9 +2,9 @@ import logging
import os import os
import shutil import shutil
import sys import sys
from collections.abc import Iterable
from contextlib import ExitStack from contextlib import ExitStack
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -15,7 +15,6 @@ from clan_lib.errors import ClanError
from clan_lib.git import commit_files from clan_lib.git import commit_files
from clan_lib.nix import nix_config, nix_shell, nix_test_store from clan_lib.nix import nix_config, nix_shell, nix_test_store
from .check import check_vars
from .prompt import Prompt, ask from .prompt import Prompt, ask
from .var import Var from .var import Var
@@ -60,9 +59,12 @@ class Generator:
dependencies: list[GeneratorKey] = field(default_factory=list) dependencies: list[GeneratorKey] = field(default_factory=list)
migrate_fact: str | None = None migrate_fact: str | None = None
validation_hash: str | None = None
machine: str | None = None machine: str | None = None
_flake: "Flake | None" = None _flake: "Flake | None" = None
_public_store: "StoreBase | None" = None
_secret_store: "StoreBase | None" = None
@property @property
def key(self) -> GeneratorKey: def key(self) -> GeneratorKey:
@@ -71,20 +73,28 @@ class Generator:
def __hash__(self) -> int: def __hash__(self) -> int:
return hash(self.key) return hash(self.key)
@cached_property @property
def exists(self) -> bool: def exists(self) -> bool:
if self.machine is None: """Check if all files for this generator exist in their respective stores."""
msg = "Machine cannot be None" if self._public_store is None or self._secret_store is None:
msg = "Stores must be set to check existence"
raise ClanError(msg) raise ClanError(msg)
if self._flake is None:
msg = "Flake cannot be None" # Check if all files exist
raise ClanError(msg) for file in self.files:
return check_vars(self.machine, self._flake, generator_name=self.name) store = self._secret_store if file.secret else self._public_store
if not store.exists(self, file.name):
return False
# Also check if validation hashes are up to date
return self._secret_store.hash_is_valid(
self
) and self._public_store.hash_is_valid(self)
@classmethod @classmethod
def get_machine_generators( def get_machine_generators(
cls: type["Generator"], cls: type["Generator"],
machine_names: list[str], machine_names: Iterable[str],
flake: "Flake", flake: "Flake",
include_previous_values: bool = False, include_previous_values: bool = False,
) -> list["Generator"]: ) -> list["Generator"]:
@@ -102,7 +112,7 @@ class Generator:
config = nix_config() config = nix_config()
system = config["system"] system = config["system"]
generators_selector = "config.clan.core.vars.generators.*.{share,dependencies,migrateFact,prompts}" generators_selector = "config.clan.core.vars.generators.*.{share,dependencies,migrateFact,prompts,validationHash}"
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}" files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
# precache all machines generators and files to avoid multiple calls to nix # precache all machines generators and files to avoid multiple calls to nix
@@ -123,7 +133,7 @@ class Generator:
generators_selector, generators_selector,
) )
if not generators_data: if not generators_data:
return [] continue
# Get all file metadata in one select # Get all file metadata in one select
files_data = flake.select_machine( files_data = flake.select_machine(
@@ -162,18 +172,30 @@ class Generator:
Prompt.from_nix(p) for p in gen_data.get("prompts", {}).values() Prompt.from_nix(p) for p in gen_data.get("prompts", {}).values()
] ]
share = gen_data["share"]
generator = cls( generator = cls(
name=gen_name, name=gen_name,
share=gen_data["share"], share=share,
files=files, files=files,
dependencies=[ dependencies=[
GeneratorKey(machine=machine_name, name=dep) GeneratorKey(
machine=None
if generators_data[dep]["share"]
else machine_name,
name=dep,
)
for dep in gen_data["dependencies"] for dep in gen_data["dependencies"]
], ],
migrate_fact=gen_data.get("migrateFact"), migrate_fact=gen_data.get("migrateFact"),
validation_hash=gen_data.get("validationHash"),
prompts=prompts, prompts=prompts,
machine=machine_name, # only set machine for machine-specific generators
# this is essential for the graph algorithms to work correctly
machine=None if share else machine_name,
_flake=flake, _flake=flake,
_public_store=pub_store,
_secret_store=sec_store,
) )
generators.append(generator) generators.append(generator)
@@ -204,14 +226,10 @@ class Generator:
return sec_store.get(self, prompt.name).decode() return sec_store.get(self, prompt.name).decode()
return None return None
def final_script(self) -> Path: def final_script(self, machine: "Machine") -> Path:
if self.machine is None:
msg = "Machine cannot be None"
raise ClanError(msg)
if self._flake is None: if self._flake is None:
msg = "Flake cannot be None" msg = "Flake cannot be None"
raise ClanError(msg) raise ClanError(msg)
machine = Machine(name=self.machine, flake=self._flake)
output = Path( output = Path(
machine.select( machine.select(
f'config.clan.core.vars.generators."{self.name}".finalScript', f'config.clan.core.vars.generators."{self.name}".finalScript',
@@ -222,16 +240,7 @@ class Generator:
return output return output
def validation(self) -> str | None: def validation(self) -> str | None:
if self.machine is None: return self.validation_hash
msg = "Machine cannot be None"
raise ClanError(msg)
if self._flake is None:
msg = "Flake cannot be None"
raise ClanError(msg)
machine = Machine(name=self.machine, flake=self._flake)
return machine.select(
f'config.clan.core.vars.generators."{self.name}".validationHash',
)
def decrypt_dependencies( def decrypt_dependencies(
self, self,
@@ -254,11 +263,6 @@ class Generator:
result: dict[str, dict[str, bytes]] = {} result: dict[str, dict[str, bytes]] = {}
for dep_key in set(self.dependencies): for dep_key in set(self.dependencies):
# For now, we only support dependencies from the same machine
if dep_key.machine != machine.name:
msg = f"Cross-machine dependencies are not supported. Generator {self.name} depends on {dep_key.name} from machine {dep_key.machine}"
raise ClanError(msg)
result[dep_key.name] = {} result[dep_key.name] = {}
dep_generator = next( dep_generator = next(
@@ -390,7 +394,7 @@ class Generator:
value = get_prompt_value(prompt.name) value = get_prompt_value(prompt.name)
prompt_file.write_text(value) prompt_file.write_text(value)
final_script = self.final_script() final_script = self.final_script(machine)
if sys.platform == "linux" and bwrap.bubblewrap_works(): if sys.platform == "linux" and bwrap.bubblewrap_works():
cmd = bubblewrap_cmd(str(final_script), tmpdir) cmd = bubblewrap_cmd(str(final_script), tmpdir)
@@ -430,6 +434,7 @@ class Generator:
self, self,
file, file,
secret_file.read_bytes(), secret_file.read_bytes(),
machine.name,
) )
secret_changed = True secret_changed = True
else: else:
@@ -437,6 +442,7 @@ class Generator:
self, self,
file, file,
secret_file.read_bytes(), secret_file.read_bytes(),
machine.name,
) )
public_changed = True public_changed = True
files_to_commit.extend(file_paths) files_to_commit.extend(file_paths)

View File

@@ -1,3 +1,5 @@
from unittest.mock import Mock
from clan_cli.vars.generator import ( from clan_cli.vars.generator import (
Generator, Generator,
GeneratorKey, GeneratorKey,
@@ -9,30 +11,69 @@ def generator_names(generator: list[Generator]) -> list[str]:
return [gen.name for gen in generator] return [gen.name for gen in generator]
def generator_keys(generator: list[Generator]) -> set[GeneratorKey]:
return {gen.key for gen in generator}
def create_mock_stores(exists_map: dict[str, bool]) -> tuple[Mock, Mock]:
"""Create mock public and secret stores with specified existence mapping."""
public_store = Mock()
secret_store = Mock()
def mock_exists(generator: Generator, _file_name: str) -> bool:
return exists_map.get(generator.name, False)
def mock_hash_valid(generator: Generator) -> bool:
return exists_map.get(generator.name, False)
public_store.exists.side_effect = mock_exists
secret_store.exists.side_effect = mock_exists
public_store.hash_is_valid.side_effect = mock_hash_valid
secret_store.hash_is_valid.side_effect = mock_hash_valid
return public_store, secret_store
def test_required_generators() -> None: def test_required_generators() -> None:
# Create mock stores
exists_map = {
"gen_1": True,
"gen_2": False,
"gen_2a": False,
"gen_2b": True,
}
public_store, secret_store = create_mock_stores(exists_map)
# Create generators with proper machine context # Create generators with proper machine context
machine_name = "test_machine" machine_name = "test_machine"
gen_1 = Generator(name="gen_1", dependencies=[], machine=machine_name) gen_1 = Generator(
name="gen_1",
dependencies=[],
machine=machine_name,
_public_store=public_store,
_secret_store=secret_store,
)
gen_2 = Generator( gen_2 = Generator(
name="gen_2", name="gen_2",
dependencies=[gen_1.key], dependencies=[gen_1.key],
machine=machine_name, machine=machine_name,
_public_store=public_store,
_secret_store=secret_store,
) )
gen_2a = Generator( gen_2a = Generator(
name="gen_2a", name="gen_2a",
dependencies=[gen_2.key], dependencies=[gen_2.key],
machine=machine_name, machine=machine_name,
_public_store=public_store,
_secret_store=secret_store,
) )
gen_2b = Generator( gen_2b = Generator(
name="gen_2b", name="gen_2b",
dependencies=[gen_2.key], dependencies=[gen_2.key],
machine=machine_name, machine=machine_name,
_public_store=public_store,
_secret_store=secret_store,
) )
gen_1.exists = True
gen_2.exists = False
gen_2a.exists = False
gen_2b.exists = True
generators: dict[GeneratorKey, Generator] = { generators: dict[GeneratorKey, Generator] = {
generator.key: generator for generator in [gen_1, gen_2, gen_2a, gen_2b] generator.key: generator for generator in [gen_1, gen_2, gen_2a, gen_2b]
} }
@@ -67,6 +108,10 @@ def test_required_generators() -> None:
def test_shared_generator_invalidates_multiple_machines_dependents() -> None: def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
# Create mock stores
exists_map = {"shared_gen": False, "gen_1": True, "gen_2": True}
public_store, secret_store = create_mock_stores(exists_map)
# Create generators with proper machine context # Create generators with proper machine context
machine_1 = "machine_1" machine_1 = "machine_1"
machine_2 = "machine_2" machine_2 = "machine_2"
@@ -74,35 +119,37 @@ def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
name="shared_gen", name="shared_gen",
dependencies=[], dependencies=[],
machine=None, # Shared generator machine=None, # Shared generator
_public_store=public_store,
_secret_store=secret_store,
) )
gen_1 = Generator( gen_1 = Generator(
name="gen_1", name="gen_1",
dependencies=[shared_gen.key], dependencies=[shared_gen.key],
machine=machine_1, machine=machine_1,
_public_store=public_store,
_secret_store=secret_store,
) )
gen_2 = Generator( gen_2 = Generator(
name="gen_2", name="gen_2",
dependencies=[shared_gen.key], dependencies=[shared_gen.key],
machine=machine_2, machine=machine_2,
_public_store=public_store,
_secret_store=secret_store,
) )
shared_gen.exists = False
gen_1.exists = True
gen_2.exists = True
generators: dict[GeneratorKey, Generator] = { generators: dict[GeneratorKey, Generator] = {
generator.key: generator for generator in [shared_gen, gen_1, gen_2] generator.key: generator for generator in [shared_gen, gen_1, gen_2]
} }
assert generator_names(all_missing_closure(generators.keys(), generators)) == [ assert generator_keys(all_missing_closure(generators.keys(), generators)) == {
"shared_gen", GeneratorKey(name="shared_gen", machine=None),
"gen_1", GeneratorKey(name="gen_1", machine=machine_1),
"gen_2", GeneratorKey(name="gen_2", machine=machine_2),
], ( }, (
"All generators should be included in all_missing_closure due to shared dependency" "All generators should be included in all_missing_closure due to shared dependency"
) )
assert generator_names(requested_closure([shared_gen.key], generators)) == [ assert generator_keys(requested_closure([shared_gen.key], generators)) == {
"shared_gen", GeneratorKey(name="shared_gen", machine=None),
"gen_1", GeneratorKey(name="gen_1", machine=machine_1),
"gen_2", GeneratorKey(name="gen_2", machine=machine_2),
], "All generators should be included in requested_closure due to shared dependency" }, "All generators should be included in requested_closure due to shared dependency"

View File

@@ -59,13 +59,13 @@ def _migrate_file(
if file.secret: if file.secret:
old_value = machine.secret_facts_store.get(service_name, fact_name) old_value = machine.secret_facts_store.get(service_name, fact_name)
paths_list = machine.secret_vars_store.set( paths_list = machine.secret_vars_store.set(
generator, file, old_value, is_migration=True generator, file, old_value, machine.name, is_migration=True
) )
paths.extend(paths_list) paths.extend(paths_list)
else: else:
old_value = machine.public_facts_store.get(service_name, fact_name) old_value = machine.public_facts_store.get(service_name, fact_name)
paths_list = machine.public_vars_store.set( paths_list = machine.public_vars_store.set(
generator, file, old_value, is_migration=True generator, file, old_value, machine.name, is_migration=True
) )
paths.extend(paths_list) paths.extend(paths_list)

View File

@@ -27,6 +27,7 @@ class FactStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str, # noqa: ARG002
) -> Path | None: ) -> Path | None:
if not self.flake.is_local: if not self.flake.is_local:
msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}" msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}"

View File

@@ -45,8 +45,8 @@ class FactStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str,
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
fact_path = self.get_dir(machine) / generator.name / var.name fact_path = self.get_dir(machine) / generator.name / var.name
fact_path.parent.mkdir(parents=True, exist_ok=True) fact_path.parent.mkdir(parents=True, exist_ok=True)
fact_path.write_bytes(value) fact_path.write_bytes(value)

View File

@@ -27,6 +27,7 @@ class SecretStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str, # noqa: ARG002
) -> Path | None: ) -> Path | None:
secret_file = self.dir / generator.name / var.name secret_file = self.dir / generator.name / var.name
secret_file.parent.mkdir(parents=True, exist_ok=True) secret_file.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -21,20 +21,20 @@ class SecretStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return True return True
def __init__(self, flake: Flake) -> None: def __init__(self, flake: Flake, pass_cmd: str | None = None) -> None:
super().__init__(flake) super().__init__(flake)
self.entry_prefix = "clan-vars" self.entry_prefix = "clan-vars"
self._store_dir: Path | None = None self._store_dir: Path | None = None
self._pass_cmd = pass_cmd
@property @property
def store_name(self) -> str: def store_name(self) -> str:
return "password_store" return "password_store"
def store_dir(self, machine: str) -> Path: def store_dir(self) -> Path:
"""Get the password store directory, cached per machine.""" """Get the password store directory, cached per machine."""
if not self._store_dir: if not self._store_dir:
result = self._run_pass( result = self._run_pass(
machine,
"git", "git",
"rev-parse", "rev-parse",
"--show-toplevel", "--show-toplevel",
@@ -46,7 +46,8 @@ class SecretStore(StoreBase):
self._store_dir = Path(result.stdout.strip().decode()) self._store_dir = Path(result.stdout.strip().decode())
return self._store_dir return self._store_dir
def _pass_command(self, machine: str) -> str: def init_pass_command(self, machine: str) -> None:
"""Initialize the password store command based on the machine's configuration."""
out_path = self.flake.select_machine( out_path = self.flake.select_machine(
machine, machine,
"config.clan.core.vars.password-store.passPackage.outPath", "config.clan.core.vars.password-store.passPackage.outPath",
@@ -63,7 +64,8 @@ class SecretStore(StoreBase):
if main_program: if main_program:
binary_path = Path(out_path) / "bin" / main_program binary_path = Path(out_path) / "bin" / main_program
if binary_path.exists(): if binary_path.exists():
return str(binary_path) self._pass_cmd = str(binary_path)
return
# Look for common password store binaries # Look for common password store binaries
bin_dir = Path(out_path) / "bin" bin_dir = Path(out_path) / "bin"
@@ -71,27 +73,34 @@ class SecretStore(StoreBase):
for binary in ["pass", "passage"]: for binary in ["pass", "passage"]:
binary_path = bin_dir / binary binary_path = bin_dir / binary
if binary_path.exists(): if binary_path.exists():
return str(binary_path) self._pass_cmd = str(binary_path)
return
# If only one binary exists, use it # If only one binary exists, use it
binaries = [f for f in bin_dir.iterdir() if f.is_file()] binaries = [f for f in bin_dir.iterdir() if f.is_file()]
if len(binaries) == 1: if len(binaries) == 1:
return str(binaries[0]) self._pass_cmd = str(binaries[0])
return
msg = "Could not find password store binary in package" msg = "Could not find password store binary in package"
raise ValueError(msg) raise ValueError(msg)
def _pass_command(self) -> str:
if not self._pass_cmd:
msg = "Password store command not initialized. This should be set during SecretStore initialization."
raise ValueError(msg)
return self._pass_cmd
def entry_dir(self, generator: Generator, name: str) -> Path: def entry_dir(self, generator: Generator, name: str) -> Path:
return Path(self.entry_prefix) / self.rel_dir(generator, name) return Path(self.entry_prefix) / self.rel_dir(generator, name)
def _run_pass( def _run_pass(
self, self,
machine: str,
*args: str, *args: str,
input: bytes | None = None, # noqa: A002 input: bytes | None = None, # noqa: A002
check: bool = True, check: bool = True,
) -> subprocess.CompletedProcess[bytes]: ) -> subprocess.CompletedProcess[bytes]:
cmd = [self._pass_command(machine), *args] cmd = [self._pass_command(), *args]
# We need bytes support here, so we can not use clan cmd. # We need bytes support here, so we can not use clan cmd.
# If you change this to run( add bytes support to it first! # If you change this to run( add bytes support to it first!
# otherwise we mangle binary secrets (which is annoying to debug) # otherwise we mangle binary secrets (which is annoying to debug)
@@ -107,39 +116,35 @@ class SecretStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str, # noqa: ARG002
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))] pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))]
self._run_pass(machine, *pass_call, input=value, check=True) self._run_pass(*pass_call, input=value, check=True)
return None # we manage the files outside of the git repo return None # we manage the files outside of the git repo
def get(self, generator: Generator, name: str) -> bytes: def get(self, generator: Generator, name: str) -> bytes:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) pass_name = str(self.entry_dir(generator, name))
return self._run_pass(machine, "show", pass_name).stdout return self._run_pass("show", pass_name).stdout
def exists(self, generator: Generator, name: str) -> bool: def exists(self, generator: Generator, name: str) -> bool:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) pass_name = str(self.entry_dir(generator, name))
# Check if the file exists with either .age or .gpg extension # Check if the file exists with either .age or .gpg extension
store_dir = self.store_dir(machine) store_dir = self.store_dir()
age_file = store_dir / f"{pass_name}.age" age_file = store_dir / f"{pass_name}.age"
gpg_file = store_dir / f"{pass_name}.gpg" gpg_file = store_dir / f"{pass_name}.gpg"
return age_file.exists() or gpg_file.exists() return age_file.exists() or gpg_file.exists()
def delete(self, generator: Generator, name: str) -> Iterable[Path]: def delete(self, generator: Generator, name: str) -> Iterable[Path]:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) pass_name = str(self.entry_dir(generator, name))
self._run_pass(machine, "rm", "--force", pass_name, check=True) self._run_pass("rm", "--force", pass_name, check=True)
return [] return []
def delete_store(self, machine: str) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
machine_dir = Path(self.entry_prefix) / "per-machine" / machine machine_dir = Path(self.entry_prefix) / "per-machine" / machine
# Check if the directory exists in the password store before trying to delete # Check if the directory exists in the password store before trying to delete
result = self._run_pass(machine, "ls", str(machine_dir), check=False) result = self._run_pass("ls", str(machine_dir), check=False)
if result.returncode == 0: if result.returncode == 0:
self._run_pass( self._run_pass(
machine,
"rm", "rm",
"--force", "--force",
"--recursive", "--recursive",
@@ -150,7 +155,6 @@ class SecretStore(StoreBase):
def generate_hash(self, machine: str) -> bytes: def generate_hash(self, machine: str) -> bytes:
result = self._run_pass( result = self._run_pass(
machine,
"git", "git",
"log", "log",
"-1", "-1",

View File

@@ -95,8 +95,9 @@ class SecretStore(StoreBase):
key_dir = sops_users_folder(self.flake.path) / user key_dir = sops_users_folder(self.flake.path) / user
return self.key_has_access(key_dir, generator, secret_name) return self.key_has_access(key_dir, generator, secret_name)
def machine_has_access(self, generator: Generator, secret_name: str) -> bool: def machine_has_access(
machine = self.get_machine(generator) self, generator: Generator, secret_name: str, machine: str
) -> bool:
self.ensure_machine_key(machine) self.ensure_machine_key(machine)
key_dir = sops_machines_folder(self.flake.path) / machine key_dir = sops_machines_folder(self.flake.path) / machine
return self.key_has_access(key_dir, generator, secret_name) return self.key_has_access(key_dir, generator, secret_name)
@@ -156,8 +157,8 @@ class SecretStore(StoreBase):
continue continue
if file.secret and self.exists(generator, file.name): if file.secret and self.exists(generator, file.name):
if file.deploy: if file.deploy:
self.ensure_machine_has_access(generator, file.name) self.ensure_machine_has_access(generator, file.name, machine)
needs_update, msg = self.needs_fix(generator, file.name) needs_update, msg = self.needs_fix(generator, file.name, machine)
if needs_update: if needs_update:
outdated.append((generator.name, file.name, msg)) outdated.append((generator.name, file.name, msg))
if file_name and not file_found: if file_name and not file_found:
@@ -177,8 +178,8 @@ class SecretStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str,
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
self.ensure_machine_key(machine) self.ensure_machine_key(machine)
secret_folder = self.secret_path(generator, var.name) secret_folder = self.secret_path(generator, var.name)
# create directory if it doesn't exist # create directory if it doesn't exist
@@ -277,9 +278,10 @@ class SecretStore(StoreBase):
secret_folder = self.secret_path(generator, name) secret_folder = self.secret_path(generator, name)
return (secret_folder / "secret").exists() return (secret_folder / "secret").exists()
def ensure_machine_has_access(self, generator: Generator, name: str) -> None: def ensure_machine_has_access(
machine = self.get_machine(generator) self, generator: Generator, name: str, machine: str
if self.machine_has_access(generator, name): ) -> None:
if self.machine_has_access(generator, name, machine):
return return
secret_folder = self.secret_path(generator, name) secret_folder = self.secret_path(generator, name)
add_secret( add_secret(
@@ -313,8 +315,9 @@ class SecretStore(StoreBase):
return keys return keys
def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]: def needs_fix(
machine = self.get_machine(generator) self, generator: Generator, name: str, machine: str
) -> tuple[bool, str | None]:
secret_path = self.secret_path(generator, name) secret_path = self.secret_path(generator, name)
current_recipients = sops.get_recipients(secret_path) current_recipients = sops.get_recipients(secret_path)
wanted_recipients = self.collect_keys_for_secret(machine, secret_path) wanted_recipients = self.collect_keys_for_secret(machine, secret_path)
@@ -373,9 +376,8 @@ class SecretStore(StoreBase):
age_plugins = load_age_plugins(self.flake) age_plugins = load_age_plugins(self.flake)
gen_machine = self.get_machine(generator)
for group in self.flake.select_machine( for group in self.flake.select_machine(
gen_machine, machine,
"config.clan.core.sops.defaultGroups", "config.clan.core.sops.defaultGroups",
): ):
allow_member( allow_member(

View File

@@ -32,8 +32,8 @@ class SecretStore(StoreBase):
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
machine: str,
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
secret_file = self.get_dir(machine) / generator.name / var.name secret_file = self.get_dir(machine) / generator.name / var.name
secret_file.parent.mkdir(parents=True, exist_ok=True) secret_file.parent.mkdir(parents=True, exist_ok=True)
secret_file.write_bytes(value) secret_file.write_bytes(value)

View File

@@ -25,7 +25,7 @@ def set_var(machine: str | Machine, var: str | Var, value: bytes, flake: Flake)
else: else:
_machine = machine _machine = machine
_var = get_machine_var(_machine, var) if isinstance(var, str) else var _var = get_machine_var(_machine, var) if isinstance(var, str) else var
paths = _var.set(value) paths = _var.set(value, _machine.name)
if paths: if paths:
commit_files( commit_files(
paths, paths,

View File

@@ -52,14 +52,14 @@ class Var:
except UnicodeDecodeError: except UnicodeDecodeError:
return "<binary blob>" return "<binary blob>"
def set(self, value: bytes) -> list[Path]: def set(self, value: bytes, machine: str) -> list[Path]:
if self._store is None: if self._store is None:
msg = "Store cannot be None" msg = "Store cannot be None"
raise ClanError(msg) raise ClanError(msg)
if self._generator is None: if self._generator is None:
msg = "Generator cannot be None" msg = "Generator cannot be None"
raise ClanError(msg) raise ClanError(msg)
return self._store.set(self._generator, self, value) return self._store.set(self._generator, self, value, machine)
@property @property
def exists(self) -> bool: def exists(self) -> bool:

View File

@@ -1,6 +1,7 @@
import importlib import importlib
import logging import logging
import pkgutil import pkgutil
import sys
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps from functools import wraps
@@ -214,6 +215,8 @@ API.register(get_system_file)
for name, func in self._registry.items(): for name, func in self._registry.items():
hints = get_type_hints(func) hints = get_type_hints(func)
print("Generating schema for function:", name, file=sys.stderr)
try: try:
serialized_hints = { serialized_hints = {
key: type_to_dict( key: type_to_dict(
@@ -236,6 +239,15 @@ API.register(get_system_file)
if ("error" in t["properties"]["status"]["enum"]) if ("error" in t["properties"]["status"]["enum"])
) )
# TODO: improve error handling in this function
if "oneOf" not in return_type:
msg = (
f"Return type of function '{name}' is not a union type. Expected a union of Success and Error types."
# @DavHau: no idea wy exactly this leads to the "oneOf" ot being present, but this should help
"Hint: When using dataclasses as return types, ensure they don't contain public fields with non-serializable types"
)
raise JSchemaTypeError(msg)
return_type["oneOf"][1] = {"$ref": "#/$defs/error"} return_type["oneOf"][1] = {"$ref": "#/$defs/error"}
sig = signature(func) sig = signature(func)

View File

@@ -7,6 +7,7 @@ from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake from clan_lib.flake.flake import Flake
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
from clan_lib.nix_models.clan import ( from clan_lib.nix_models.clan import (
InventoryInstance,
InventoryMachine, InventoryMachine,
) )
from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.inventory_store import InventoryStore
@@ -41,28 +42,68 @@ class MachineState(TypedDict):
# add more info later when retrieving remote state # add more info later when retrieving remote state
@dataclass
class MachineResponse:
data: InventoryMachine
# Reference the installed service instances
instance_refs: set[str] = field(default_factory=set)
def machine_instances(
machine_name: str,
instances: dict[str, InventoryInstance],
tag_map: dict[str, set[str]],
) -> set[str]:
res: set[str] = set()
for instance_name, instance in instances.items():
for role in instance.get("roles", {}).values():
if machine_name in role.get("machines", {}):
res.add(instance_name)
for tag in role.get("tags", {}):
if tag in tag_map and machine_name in tag_map[tag]:
res.add(instance_name)
return res
@API.register @API.register
def list_machines( def list_machines(
flake: Flake, flake: Flake,
opts: ListOptions | None = None, opts: ListOptions | None = None,
) -> dict[str, InventoryMachine]: ) -> dict[str, MachineResponse]:
"""List machines of a clan""" """List machines of a clan"""
inventory_store = InventoryStore(flake=flake) inventory_store = InventoryStore(flake=flake)
inventory = inventory_store.read() inventory = inventory_store.read()
machines = inventory.get("machines", {}) raw_machines = inventory.get("machines", {})
if opts and opts.filter.tags is not None: tag_map: dict[str, set[str]] = {}
filtered_machines = {}
for machine_name, machine in machines.items(): for machine_name, machine in raw_machines.items():
for tag in machine.get("tags", []):
if tag not in tag_map:
tag_map[tag] = set()
tag_map[tag].add(machine_name)
instances = inventory.get("instances", {})
res: dict[str, MachineResponse] = {}
for machine_name, machine in raw_machines.items():
m = MachineResponse(
data=InventoryMachine(**machine),
instance_refs=machine_instances(machine_name, instances, tag_map),
)
# Check filters
if opts and opts.filter.tags is not None:
machine_tags = machine.get("tags", []) machine_tags = machine.get("tags", [])
if all(ft in machine_tags for ft in opts.filter.tags): if not all(ft in machine_tags for ft in opts.filter.tags):
filtered_machines[machine_name] = machine continue
return filtered_machines res[machine_name] = m
return machines return res
@API.register @API.register

View File

@@ -67,6 +67,33 @@ def test_list_inventory_machines(clan_flake: Callable[..., Flake]) -> None:
assert list(machines.keys()) == ["jon", "sara", "vanessa"] assert list(machines.keys()) == ["jon", "sara", "vanessa"]
@pytest.mark.with_core
def test_list_machines_instance_refs(clan_flake: Callable[..., Flake]) -> None:
flake = clan_flake(
{
"inventory": {
"machines": {
"jon": {},
"sara": {},
},
"instances": {
"admin": {
"roles": {"default": {"machines": {"jon": {}}}},
},
"borgbackup": {
"roles": {"default": {"tags": {"all": {}}}},
},
},
},
},
)
machines = list_machines(flake)
assert machines["sara"].instance_refs == set({"borgbackup"})
assert machines["jon"].instance_refs == set({"admin", "borgbackup"})
@pytest.mark.with_core @pytest.mark.with_core
def test_set_machine_no_op(clan_flake: Callable[..., Flake]) -> None: def test_set_machine_no_op(clan_flake: Callable[..., Flake]) -> None:
flake = clan_flake( flake = clan_flake(

View File

@@ -1,5 +1,6 @@
import json import json
import logging import logging
import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -91,6 +92,15 @@ def run_machine_hardware_info(
str(opts.backend.config_path(machine)), str(opts.backend.config_path(machine)),
] ]
environ = os.environ.copy()
if target_host.password:
cmd += [
"--env-password",
"--ssh-option",
"IdentitiesOnly=yes",
]
environ["SSHPASS"] = target_host.password
if target_host.private_key: if target_host.private_key:
cmd += ["--ssh-option", f"IdentityFile={target_host.private_key}"] cmd += ["--ssh-option", f"IdentityFile={target_host.private_key}"]
@@ -113,7 +123,9 @@ def run_machine_hardware_info(
run( run(
cmd, cmd,
RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True), RunOpts(
log=Log.BOTH, prefix=machine.name, needs_user_terminal=True, env=environ
),
) )
print(f"Successfully generated: {hw_file}") print(f"Successfully generated: {hw_file}")

View File

@@ -122,9 +122,6 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
phases=["partitioning"], phases=["partitioning"],
) )
if target_host.password:
os.environ["SSHPASS"] = target_host.password
cmd = [ cmd = [
"nixos-anywhere", "nixos-anywhere",
"--flake", "--flake",
@@ -161,12 +158,14 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
], ],
) )
environ = os.environ.copy()
if target_host.password: if target_host.password:
cmd += [ cmd += [
"--env-password", "--env-password",
"--ssh-option", "--ssh-option",
"IdentitiesOnly=yes", "IdentitiesOnly=yes",
] ]
environ["SSHPASS"] = target_host.password
# Always set a nixos-anywhere private key to prevent failures when running # Always set a nixos-anywhere private key to prevent failures when running
# 'clan install --phases kexec' followed by 'clan install --phases disko,install,reboot'. # 'clan install --phases kexec' followed by 'clan install --phases disko,install,reboot'.
@@ -226,7 +225,12 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
notify_install_step(notification) notify_install_step(notification)
run( run(
[*cmd, "--phases", phase], [*cmd, "--phases", phase],
RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True), RunOpts(
log=Log.BOTH,
prefix=machine.name,
needs_user_terminal=True,
env=environ,
),
) )
if opts.phases: if opts.phases:

View File

@@ -30,7 +30,9 @@ def list_full_machines(flake: Flake) -> dict[str, Machine]:
"""Like `list_machines`, but returns a full 'machine' instance for each machine.""" """Like `list_machines`, but returns a full 'machine' instance for each machine."""
machines = list_machines(flake) machines = list_machines(flake)
return instantiate_inventory_to_machines(flake, machines) return instantiate_inventory_to_machines(
flake, {name: m.data for name, m in machines.items()}
)
@dataclass @dataclass

View File

@@ -95,9 +95,14 @@ class Machine:
@cached_property @cached_property
def secret_vars_store(self) -> StoreBase: def secret_vars_store(self) -> StoreBase:
from clan_cli.vars.secret_modules import password_store # noqa: PLC0415
secret_module = self.select("config.clan.core.vars.settings.secretModule") secret_module = self.select("config.clan.core.vars.settings.secretModule")
module = importlib.import_module(secret_module) module = importlib.import_module(secret_module)
return module.SecretStore(flake=self.flake) store = module.SecretStore(flake=self.flake)
if isinstance(store, password_store.SecretStore):
store.init_pass_command(machine=self.name)
return store
@cached_property @cached_property
def public_vars_store(self) -> StoreBase: def public_vars_store(self) -> StoreBase:

View File

@@ -87,49 +87,36 @@ def check_machine_ssh_reachable(
f"Checking SSH reachability for {remote.target} on port {remote.port or 22}", f"Checking SSH reachability for {remote.target} on port {remote.port or 22}",
) )
# Use ssh with ProxyCommand to check through SOCKS5
cmd = [ cmd = [
"ssh", "nc",
] ]
# If using SOCKS5 proxy, add ProxyCommand # If using SOCKS5 proxy, add -x
if remote.socks_port: if remote.socks_port:
cmd.extend( cmd.extend(
[ [
"-o", "-X",
f"ProxyCommand=nc -X 5 -x localhost:{remote.socks_port} %h %p", "5",
"-x",
f"localhost:{remote.socks_port}",
], ],
) )
cmd.extend( cmd.extend(
[ [
"-o", "-z",
"BatchMode=yes", "-w",
"-o", str(opts.timeout),
"StrictHostKeyChecking=no", str(remote.address.strip()),
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
f"ConnectTimeout={opts.timeout}",
"-o",
"PreferredAuthentications=none",
"-p",
str(remote.port or 22), str(remote.port or 22),
f"dummy@{remote.address.strip()}",
"true",
], ],
) )
try: try:
res = run(cmd, options=RunOpts(timeout=opts.timeout, check=False)) res = run(cmd, options=RunOpts(timeout=opts.timeout, check=False))
# SSH will fail with authentication error if server is reachable if "succeeded" in res.stderr:
# Check for SSH-related errors in stderr return
if (
"Permission denied" in res.stderr
or "No supported authentication" in res.stderr
):
return # Server is reachable, auth failed as expected
msg = "Connection failed: SSH server not reachable" msg = "Connection failed: SSH server not reachable"
raise ClanError(msg) raise ClanError(msg)

View File

@@ -8,10 +8,13 @@ from clan_cli.vars.migration import check_can_migrate, migrate_files
from clan_lib.api import API from clan_lib.api import API
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.machines.actions import list_machines
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
debug_condition = False
@API.register @API.register
def get_generators( def get_generators(
@@ -32,27 +35,46 @@ def get_generators(
List of generators based on the specified selection and closure mode. List of generators based on the specified selection and closure mode.
""" """
machine_names = [machine.name for machine in machines] if not machines:
vars_generators = Generator.get_machine_generators( msg = "At least one machine must be provided"
machine_names, raise ClanError(msg)
all_machines = list_machines(machines[0].flake).keys()
requested_machines = [machine.name for machine in machines]
all_generators_list = Generator.get_machine_generators(
all_machines,
machines[0].flake, machines[0].flake,
include_previous_values=include_previous_values, include_previous_values=include_previous_values,
) )
generators = {generator.key: generator for generator in vars_generators} requested_generators_list = Generator.get_machine_generators(
requested_machines,
machines[0].flake,
include_previous_values=include_previous_values,
)
all_generators = {generator.key: generator for generator in all_generators_list}
requested_generators = {
generator.key: generator for generator in requested_generators_list
}
result_closure = [] result_closure = []
if generator_name is None: # all generators selected if generator_name is None: # all generators selected
if full_closure: if full_closure:
result_closure = graph.requested_closure(generators.keys(), generators) result_closure = graph.requested_closure(
requested_generators.keys(), all_generators
)
else: else:
result_closure = graph.all_missing_closure(generators.keys(), generators) result_closure = graph.all_missing_closure(
requested_generators.keys(), all_generators
)
# specific generator selected # specific generator selected
elif full_closure: elif full_closure:
roots = [key for key in generators if key.name == generator_name] roots = [key for key in requested_generators if key.name == generator_name]
result_closure = requested_closure(roots, generators) result_closure = requested_closure(roots, all_generators)
else: else:
roots = [key for key in generators if key.name == generator_name] roots = [key for key in requested_generators if key.name == generator_name]
result_closure = graph.all_missing_closure(roots, generators) result_closure = graph.all_missing_closure(roots, all_generators)
return result_closure return result_closure
@@ -123,6 +145,9 @@ def run_generators(
executing the generator. executing the generator.
""" """
if not machines:
msg = "At least one machine must be provided"
raise ClanError(msg)
if isinstance(generators, list): if isinstance(generators, list):
# List of generator names - use them exactly as provided # List of generator names - use them exactly as provided
if len(generators) == 0: if len(generators) == 0:
@@ -143,23 +168,23 @@ def run_generators(
prompt_values = { prompt_values = {
generator.name: prompt_values(generator) for generator in generator_objects generator.name: prompt_values(generator) for generator in generator_objects
} }
# execute health check # execute health check
for machine in machines: for machine in machines:
_ensure_healthy(machine=machine) _ensure_healthy(machine=machine)
# execute generators # execute generators
for generator in generator_objects: for generator in generator_objects:
generator_machines = ( machine = (
machines machines[0]
if generator.machine is None if generator.machine is None
else [Machine(name=generator.machine, flake=machines[0].flake)] else Machine(name=generator.machine, flake=machines[0].flake)
) )
for machine in generator_machines: if check_can_migrate(machine, generator):
if check_can_migrate(machine, generator): migrate_files(machine, generator)
migrate_files(machine, generator) else:
else: generator.execute(
generator.execute( machine=machine,
machine=machine, prompt_values=prompt_values.get(generator.name, {}),
prompt_values=prompt_values.get(generator.name, {}), no_sandbox=no_sandbox,
no_sandbox=no_sandbox, )
)

View File

@@ -24,6 +24,9 @@
let let
pyDeps = ps: [ pyDeps = ps: [
ps.argcomplete # Enables shell completions ps.argcomplete # Enables shell completions
# uncomment web-pdb for debugging:
# (pkgs.callPackage ./python-deps.nix {}).web-pdb
]; ];
devDeps = ps: [ devDeps = ps: [
ps.ipython ps.ipython