Compare commits

..

1 Commits

Author SHA1 Message Date
pinpox
71407f88bf Add yggdrasil clanService 2025-09-16 10:13:55 +02:00
115 changed files with 1310 additions and 3220 deletions

View File

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

View File

@@ -50,7 +50,6 @@
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

@@ -1,10 +0,0 @@
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,23 +18,27 @@
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 = [ imports = [ self.nixosModules.test-install-machine-without-system ];
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 = import ./facter-report.nix pkgs.hostPlatform.system; facter.reportPath = builtins.fetchurl {
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, ... }:
@@ -155,7 +159,6 @@
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,7 +35,6 @@
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,7 +112,6 @@
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,5 +1,4 @@
{ ... }: { ... }:
{ {
_class = "clan.service"; _class = "clan.service";
manifest.name = "coredns"; manifest.name = "coredns";
@@ -26,12 +25,6 @@
# 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 =
@@ -49,8 +42,8 @@
}: }:
{ {
networking.firewall.allowedTCPPorts = [ settings.dnsPort ]; networking.firewall.allowedTCPPorts = [ 53 ];
networking.firewall.allowedUDPPorts = [ settings.dnsPort ]; networking.firewall.allowedUDPPorts = [ 53 ];
services.coredns = services.coredns =
let let
@@ -81,19 +74,13 @@
in in
{ {
enable = true; enable = true;
config = config = ''
. {
let
dnsPort = builtins.toString settings.dnsPort;
in
''
.:${dnsPort} {
forward . 1.1.1.1 forward . 1.1.1.1
cache 30 cache 30
} }
${settings.tld}:${dnsPort} { ${settings.tld} {
file ${zonefile} file ${zonefile}
} }
''; '';
@@ -120,16 +107,10 @@
# 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, settings, ... }: { roles, ... }:
{ {
nixosModule = nixosModule =
{ lib, ... }: { lib, ... }:
@@ -166,7 +147,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}@${builtins.toString settings.dnsPort}"; stub-addr = "${roles.server.machines.${m}.settings.ip}";
}) (lib.attrNames roles.server.machines); }) (lib.attrNames roles.server.machines);
}; };
}; };

View File

@@ -95,15 +95,18 @@
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 -p 1053 one.foo") answer = client.succeed("dig @192.168.1.2 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 -p 1053 two.foo") answer = client.succeed("dig @192.168.1.2 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,11 +56,6 @@
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}";
}; };

View File

@@ -0,0 +1,33 @@
This module sets up [yggdrasil](https://yggdrasil-network.github.io/) across
your clan.
Yggdrasil is designed to be a future-proof and decentralised alternative to
the structured routing protocols commonly used today on the internet. Inside
your clan, it will allow you reaching all of your machines.
## Example Usage
While you can specify statically configured peers for each host, yggdrasil does
auto-discovery of local peers.
```nix
inventory = {
machines = {
peer1 = { };
peer2 = { };
};
instances = {
yggdrasil = {
# Deploy on all machines
roles.default.tags.all = { };
# Or individual hosts
roles.default.machines.peer1 = { };
roles.default.machines.peer2 = { };
};
};
};
```

View File

@@ -1,108 +1,116 @@
# Example clan service. See https://docs.clan.lol/guides/services/community/
# for more details
# The test for this module in ./tests/vm/default.nix shows an example of how
# the service is used.
{ packages }:
{ ... }: { ... }:
{ {
_class = "clan.service"; _class = "clan.service";
manifest.name = "clan-core/yggdrasil"; manifest.name = "clan-core/yggdrasil";
manifest.description = "Yggdrasil VPN"; manifest.description = "Yggdrasil encrypted IPv6 routing overlay network";
roles.default = { roles.default = {
# interface = interface =
# { lib, ... }: { lib, ... }:
# {
# # Here we define the settings for this role. They will be accessible
# # via `roles.morning.settings` in the role
#
# options.greeting = lib.mkOption {
# type = lib.types.str;
# default = "Good morning";
# description = "The greeting to use";
# };
# };
# Maps over all instances and produces one result per instance.
perInstance =
{ {
# Role settings for this machine/instance options.extraMulticastInterfaces = lib.mkOption {
settings, type = lib.types.listOf lib.types.attrs;
default = [ ];
description = ''
Additional interfaces to use for Multicast. See
https://yggdrasil-network.github.io/configurationref.html#multicastinterfaces
for reference.
'';
example = [
{
Regex = "(wg).*";
Beacon = true;
Listen = true;
Port = 5400;
Priority = 1020;
}
];
};
# The name of this instance of the service options.peers = lib.mkOption {
instanceName, type = lib.types.listOf lib.types.str;
default = [ ];
# The current machine description = ''
machine, Static peers to configure for this host.
If not set, local peers will be auto-discovered
# All roles of this service, with their assigned machines '';
roles, example = [
"tcp://192.168.1.1:6443"
"quic://192.168.1.1:6443"
"tls://192.168.1.1:6443"
"ws://192.168.1.1:6443"
];
};
};
perInstance =
{ settings, ... }:
{
nixosModule =
{
config,
pkgs,
... ...
}: }:
{
# Analog to 'perSystem' of flake-parts.
# For every instance of this service we will add a nixosModule to a morning-machine
nixosModule =
{ config, pkgs, ... }:
{ {
clan.core.vars.generators.yggdrasil = { clan.core.vars.generators.yggdrasil = {
files.privateKey = { }; files.privateKey = { };
files.publicKey.secret = false;
files.address.secret = false;
runtimeInputs = with pkgs; [ runtimeInputs = with pkgs; [
yggdrasil yggdrasil
jq jq
openssl
]; ];
script = '' script = ''
yggdrasil -genconf -json | jq 'to_entries|map(select(.key|endswith("Key")))|from_entries' > $out/privateKey # Generate private key
openssl genpkey -algorithm Ed25519 -out $out/privateKey
# Generate corresponding public key
openssl pkey -in $out/privateKey -pubout -out $out/publicKey
# Derive IPv6 address from key
echo "{ \"PrivateKeyPath\": \"$out/privateKey\"}" | yggdrasil -useconf -address > $out/address
''; '';
}; };
services.yggdrasil = {
persistentKeys = true;
enable = true;
};
systemd.services.yggdrasil.serviceConfig.BindReadOnlyPaths = [ systemd.services.yggdrasil.serviceConfig.BindReadOnlyPaths = [
"${config.clan.core.vars.generators.yggdrasil.files.privateKey.path}:/var/lib/yggdrasil/keys.json" "${config.clan.core.vars.generators.yggdrasil.files.privateKey.path}:/var/lib/yggdrasil/key"
]; ];
# Interaction examples what you could do here: services.yggdrasil = {
# - Get some settings of this machine enable = true;
# settings.ipRanges openMulticastPort = true;
# persistentKeys = true;
# - Get all evening names: settings = {
# allEveningNames = lib.attrNames roles.evening.machines PrivateKeyPath = "/var/lib/yggdrasil/key";
# IfName = "ygg";
# - Get all roles of the machine: Peers = settings.peers;
# machine.roles MulticastInterfaces = [
# # Ethernet is preferred over WIFI
# - Get the settings that where applied to a specific evening machine: {
# roles.evening.machines.peer1.settings Regex = "(eth|en).*";
# environment.etc.hello.text = "${settings.greeting} World!"; Beacon = true;
Listen = true;
Port = 5400;
Priority = 1024;
}
{
Regex = "(wl).*";
Beacon = true;
Listen = true;
Port = 5400;
Priority = 1025;
}
]
++ settings.extraMulticastInterfaces;
};
};
networking.firewall.allowedTCPPorts = [ 5400 ];
}; };
}; };
}; };
# This part gets applied to all machines, regardless of their role.
# perMachine =
# { machine, ... }:
# {
# nixosModule =
# { pkgs, ... }:
# {
# environment.systemPackages = [
# (pkgs.writeShellScriptBin "greet-world" ''
# #!${pkgs.bash}/bin/bash
# set -euo pipefail
#
# cat /etc/hello
# echo " I'm ${machine.name}"
# '')
# ];
# };
# };
} }

View File

@@ -1,6 +1,5 @@
{ {
self, self,
inputs,
lib, lib,
... ...
}: }:

View File

@@ -2,12 +2,12 @@
name = "yggdrasil"; name = "yggdrasil";
clan = { clan = {
test.useContainers = false;
directory = ./.; directory = ./.;
inventory = { inventory = {
machines.peer1 = { }; machines.peer1 = { };
machines.peer2 = { };
# machines.peer2 = { };
instances."yggdrasil" = { instances."yggdrasil" = {
module.name = "yggdrasil"; module.name = "yggdrasil";
@@ -15,27 +15,79 @@
# Assign the roles to the two machines # Assign the roles to the two machines
roles.default.machines.peer1 = { }; roles.default.machines.peer1 = { };
roles.default.machines.peer2 = { };
# roles.evening.machines.peer2 = {
# # Set roles settings for the peers, where we want to differ from
# # the role defaults
# settings = {
# greeting = "Good night";
# };
# };
}; };
}; };
}; };
testScript = # TODO remove after testing, this is just to make @pinpox' life easier
{ ... }: nodes =
'' let
c =
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [ net-tools ];
console = {
font = "Lat2-Terminus16";
keyMap = "colemak";
};
};
in
{
peer1 = c;
peer2 = c;
};
testScript = ''
start_all() start_all()
# value = peer1.succeed("greet-world") # Wait for both machines to be ready
# assert value.strip() == "Good morning World! I'm peer1", value peer1.wait_for_unit("multi-user.target")
peer2.wait_for_unit("multi-user.target")
# Check that yggdrasil service is running on both machines
peer1.wait_for_unit("yggdrasil")
peer2.wait_for_unit("yggdrasil")
peer1.succeed("systemctl is-active yggdrasil")
peer2.succeed("systemctl is-active yggdrasil")
# Check that both machines have yggdrasil network interfaces
# Yggdrasil creates a tun interface (usually tun0)
peer1.wait_until_succeeds("ip link show | grep -E 'ygg'", 30)
peer2.wait_until_succeeds("ip link show | grep -E 'ygg'", 30)
# Get yggdrasil IPv6 addresses from both machines
peer1_ygg_ip = peer1.succeed("yggdrasilctl -json getself | jq -r '.address'").strip()
peer2_ygg_ip = peer2.succeed("yggdrasilctl -json getself | jq -r '.address'").strip()
# TODO: enable this check. Values don't match up yet, but I can't
# update-vars to test, because the script is borken.
# Compare runtime addresses with saved addresses from vars
# expected_peer1_ip = "${builtins.readFile ./vars/per-machine/peer1/yggdrasil/address/value}"
# expected_peer2_ip = "${builtins.readFile ./vars/per-machine/peer2/yggdrasil/address/value}"
print(f"peer1 yggdrasil IP: {peer1_ygg_ip}")
print(f"peer2 yggdrasil IP: {peer2_ygg_ip}")
# print(f"peer1 expected IP: {expected_peer1_ip}")
# print(f"peer2 expected IP: {expected_peer2_ip}")
# #
# value = peer2.succeed("greet-world") # # Verify that runtime addresses match expected addresses
# assert value.strip() == "Good night World! I'm peer2", value # assert peer1_ygg_ip == expected_peer1_ip, f"peer1 runtime IP {peer1_ygg_ip} != expected IP {expected_peer1_ip}"
# assert peer2_ygg_ip == expected_peer2_ip, f"peer2 runtime IP {peer2_ygg_ip} != expected IP {expected_peer2_ip}"
# Wait a bit for the yggdrasil network to establish connectivity
import time
time.sleep(10)
# Test connectivity: peer1 should be able to ping peer2 via yggdrasil
peer1.succeed(f"ping -6 -c 3 {peer2_ygg_ip}")
# Test connectivity: peer2 should be able to ping peer1 via yggdrasil
peer2.succeed(f"ping -6 -c 3 {peer1_ygg_ip}")
''; '';
} }

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:3dolkgdLC4y5fps4gGb9hf4QhwkUUBodlMOKT+/+erO70FB/pzYBg0mQjQy/uqjINzfIiM32iwVDnx3/Yyz5BDRo2CK+83UGEi4=,iv:FRp1HqlU06JeyEXXFO5WxJWxeLnmUJRWGuFKcr4JFOM=,tag:rbi30HJuqPHdU/TqInGXmg==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoYXBxS1JuNW9NeC9YU0xY\nK2xQWDhUYjZ4VzZmeUw1aG9UN2trVnBGQ0J3Ckk0V3d0UFBkT0RnZjBoYjNRVEVW\nN2VEdCtUTUUwenhJSEErT0MyWDA2bHMKLS0tIHJJSzVtR3NCVXozbzREWjltN2ZG\nZm44Y1c4MWNIblcxbmt2YkdxVE10Z1UKmJKEjiYZ9U47QACkbacNTirQIcCvFjM/\nwVxSEVq524sK8LCyIEvsG4e3I3Kn0ybZjoth7J/jg7J4gb8MVw+leQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-16T08:13:06Z",
"mac": "ENC[AES256_GCM,data:6HJDkg0AWz+zx5niSIyBAGGaeemwPOqTCA/Fa6VjjyCh1wOav3OTzy/DRBOCze4V52hMGV3ULrI2V7G7DdvQy6LqiKBTQX5ZbWm3IxLASamJBjUJ1LvTm97WvyL54u/l2McYlaUIC8bYDl1UQUqDMo9pN4GwdjsRNCIl4O0Z7KY=,iv:zkWfYuhqwKpZk/16GlpKdAi2qS6LiPvadRJmxp2ZW+w=,tag:qz1gxVnT3OjWxKRKss5W8w==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

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

View File

@@ -0,0 +1,15 @@
{
"data": "ENC[AES256_GCM,data:BW15ydnNpr0NIXu92nMsD/Y52BDEOsdZg2/fiM8lwSTJN3lEymrIBYsRrcPAnGpFb52d7oN8zdNz9WoW3f/Xwl136sWDz/sc0k4=,iv:7m77nOR/uXLMqXB5QmegtoYVqByJVFFqZIVOtlAonzg=,tag:8sUo9DRscNRajrk+CzHzHw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLVWpnSlJOTVU4NWRMSCto\nS0RaR2RCTUJjT1J0VzRPVTdPL2N5Yjl3c0EwCmlabm1aSzdlV29nb3lrZFBEZXR6\nRjI2TGZUNW1KQ3pLbDFscUlKSnVBNWcKLS0tIDlLR1VFSTRHeWNiQ29XK1pUUnlr\nVkVHOXdJeHhpcldYNVhpK1V6Nng0eW8KSsqJejY1kll6bUBUngiolCB7OhjyI0Gc\nH+9OrORt/nLnc51eo/4Oh9vp/dvSZzuW9MOF9m0f6B3WOFRVMAbukQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-16T08:13:15Z",
"mac": "ENC[AES256_GCM,data:dyLnGXBC4nGOgX2TrGhf8kI/+Et0PRy+Ppr228y3LYzgcmUunZl9R8+QXJN51OJSQ63gLun5TBw0v+3VnRVBodlhqTDtfACJ7eILCiArPJqeZoh5MR6HkF31yfqTRlXl1i6KHRPVWvjRIdwJ9yZVN1XNAUsxc7xovqS6kkkGPsA=,iv:7yXnpbU7Zf7GH1+Uimq8eXDUX1kO/nvTaGx4nmTrKdM=,tag:WNn9CUOdCAlksC0Qln5rVg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

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

View File

@@ -0,0 +1 @@
200:91bb:f1ec:c580:6d52:70b3:4d60:7bf2

View File

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

View File

@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:/YoEoYY8CmqK4Yk4fmZieIHIvRn779aikoo3+6SWI5SxuU8TLJVY9+Q7mRmnbCso/8RPMICWkZMIkfbxYi6Dwc4UFmLwPqCoeAYsFBiHsJ6QUoTm1qtDDfXcruFs8Mo93ZmJb7oJIC0a+sVbB5L1NsGmG3g+a+g=,iv:KrMjRIQXutv9WdNzI5VWD6SMDnGzs9LFWcG2d9a6XDg=,tag:x5gQN9FaatRBcHOyS2cicw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwQ0FNU1c4RDNKTHRtMy8z\nSEtQRzFXTVFvcitMWjVlMURPVkxsZC9wU25nCmt4TS81bnJidzFVZkxEY0ovWUtm\nVk5PMjZEWVJCei9rVTJ2bG1ZNWJoZGMKLS0tIHgyTEhIdUQ3YnlKVi9lNVpUZ0dI\nd3BLL05oMXFldGVKbkpoaklscDJMR3MKpUl/KNPrtyt4/bu3xXUAQIkugQXWjlPf\nFqFc1Vnqxynd+wJkkd/zYs4XcOraogOUj/WIRXkqXgdDDoEqb/VIBg==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1r264u9yngfq8qkrveh4tn0rhfes02jfgrtqufdx4n4m3hs4rla2qx0rk4d",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOUdkd3VVSTU3NHZ6aURB\na2dYMXhyMmVLMDVlM0dzVHpxbUw3K3BFcVNzCm1LczFyd3BubGwvRVUwQ1Q0aWZR\nL1hlb1VpZ3JnTVQ4Zm9wVnlJYVNuL00KLS0tIHlMRVMyNW9rWG45bVVtczF3MVNq\nL2d2RXhEeVcyRVNmSUF6cks5VStxVkUKugI1iDei32852wNV/zPlyVwKJH1UXOlY\nFQq7dqMJMWI6a5F+z4UdaHvzyKxF2CWBG7DVnaUSpq7Q3uGmibsSOQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-16T08:13:07Z",
"mac": "ENC[AES256_GCM,data:LIlgQgiQt9aHXagpXphxSnpju+DOxuBvPpz5Rr43HSwgbWFgZ8tqlH2C1xo2xsJIexWkc823J9txpy+PLFXSm4/NbQGbKSymjHNEIYaU1tBSQ0KZ+s22X3/ku3Hug7/MkEKv5JsroTEcu3FK6Fv7Mo0VWqUggenl9AsJ5BocUO4=,iv:LGOnpWsod1ek4isWVrHrS+ZOCPrhwlPliPOTiMVY0zY=,tag:tRuHBSd9HxOswNcqjvzg0w==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

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

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAtyIHCZ0/yVbHpllPwgaWIFQ3Kb4fYMcOujgVmttA7gM=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1 @@
200:bb1f:6f1c:1852:173a:cb5e:5726:870

View File

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

View File

@@ -0,0 +1,19 @@
{
"data": "ENC[AES256_GCM,data:b1dbaJQGr8mnISch0iej+FhMnYOIFxOJYCvWDQseiczltXsBetbYr+89co5Sp7wmhQrH3tlWaih3HZe294Y9j8XvwpNUtmW3RZHsU/6EWA50LKcToFGFCcEBM/Nz9RStQXnjwLbRSLFuMlfoQttUATB2XYSm+Ng=,iv:YCeE3KbHaBhR0q10qO8Og1LBT5OUjsIDxfclpcLJh6I=,tag:M7y9HAC+fh8Fe8HoqQrnbg==,type:str]",
"sops": {
"age": [
{
"recipient": "age1p8kuf8s0nfekwreh4g38cgghp4nzszenx0fraeyky2me0nly2scstqunx8",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3NTVOT2MxaDJsTXloVVcv\nellUdnVxSVdnZ1NBUGEwLzBiTGoyZENJdm1RClp5eHY3dkdVSzVJYk52dWFCQnlG\nclIrQUJ5RXRYTythWTFHR1NhVHlyMVkKLS0tIEFza3YwcUNiYUV5VWJQcTljY2ZR\nUnc3U1VubmZRTCtTTC9rd1kydnNYa00KqdwV3eRHA6Y865JXQ7lxbS6aTIGf/kQM\nqDFdiUdvEDqo19Df3QBJ7amQ1YjPqSIRbO8CJNPI8JqQJKTaBOgm9g==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzTmV0Skd5Zzk1SXc4ZDc3\nRi9wTVdDM1lTc3N0MXpNNVZjUWJ6VDZHd3hzCkpRZnNtSU14clkybWxvSEhST2py\nR29jcHdXSCtFRE02ejB0dzN1eGVQZ1kKLS0tIE9YVjJBRTg1SGZ5S3lYdFRUM3RW\nOGZjUEhURnJIVTBnZG43UFpTZkdseFUKOgHC10Rqf/QnzfCHUMEPb1PVo9E6qlpo\nW/F1I8ZqkFI8sWh54nilXeR8i8w+QCthliBxsxdDTv2FSxdnKNHu3A==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-09-16T08:13:15Z",
"mac": "ENC[AES256_GCM,data:0byytsY3tFK3r4qhM1+iYe9KYYKJ8cJO/HonYflbB0iTD+oRBnnDUuChPdBK50tQxH8aInlvgIGgi45OMk7IrFBtBYQRgFBUR5zDujzel9hJXQvpvqgvRMkzA542ngjxYmZ74mQB+pIuFhlVJCfdTN+smX6N4KyDRj9d8aKK0Qs=,iv:DC8nwgUAUSdOCr8TlgJX21SxOPOoJKYeNoYvwj5b9OI=,tag:cbJ8M+UzaghkvtEnRCp+GA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

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

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAonBIcfPW9GKaUNRs+8epsgQOShNbR9v26+3H80an2/c=
-----END PUBLIC KEY-----

12
devFlake/flake.lock generated
View File

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

View File

@@ -113,6 +113,7 @@ nav:
- reference/clanServices/users.md - reference/clanServices/users.md
- reference/clanServices/wifi.md - reference/clanServices/wifi.md
- reference/clanServices/wireguard.md - reference/clanServices/wireguard.md
- reference/clanServices/yggdrasil.md
- reference/clanServices/zerotier.md - reference/clanServices/zerotier.md
- API: reference/clanServices/clan-service-author-interface.md - API: reference/clanServices/clan-service-author-interface.md

View File

@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
# Direct SSH with fallback support # Direct SSH with fallback support
internet = { internet = {
roles.default.machines.server1 = { roles.default.machines.server1 = {
settings.host = "server1.example.com"; settings.address = "server1.example.com";
}; };
roles.default.machines.server2 = { roles.default.machines.server2 = {
settings.host = "192.168.1.100"; settings.address = "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.host = "public.example.com"; settings.address = "public.example.com";
}; };
}; };

18
flake.lock generated
View File

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

View File

@@ -109,7 +109,6 @@ 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,11 +5,6 @@ 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")
@@ -77,10 +72,6 @@ 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]
@@ -114,10 +105,6 @@ 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,7 +1,6 @@
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
@@ -12,10 +11,7 @@ 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 ( from ._webview_ffi import _encode_c_string, _webview_lib
_encode_c_string,
_webview_lib,
)
from .webview_bridge import WebviewBridge from .webview_bridge import WebviewBridge
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -36,21 +32,6 @@ 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
@@ -65,7 +46,6 @@ 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
@@ -76,13 +56,6 @@ 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
# 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) handle = _webview_lib.webview_create(int(with_debugger), self.window)
callbacks: dict[str, Callable[..., Any]] = {} callbacks: dict[str, Callable[..., Any]] = {}
@@ -244,21 +217,6 @@ 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,11 +11,6 @@
gobject-introspection, gobject-introspection,
gtk4, gtk4,
lib, lib,
stdenv,
# macOS-specific dependencies
imagemagick,
makeWrapper,
libicns,
}: }:
let let
source = source =
@@ -96,12 +91,7 @@ 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
@@ -158,113 +148,16 @@ 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
${lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
mkdir -p $out/share/icons/hicolor mkdir -p $out/share/icons/hicolor
cp -r ./clan_app/assets/white-favicons/* $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
@@ -278,7 +171,6 @@ 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=";
}; };
archivoSemi_ttf = fetchurl { commitMono_ttf = fetchurl {
url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf"; url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w="; hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
}; };
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 ${archivoSemi_ttf} $out/ArchivoSemiCondensed-Medium.ttf cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
'' ''

View File

@@ -1,5 +1,10 @@
#!/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
@@ -9,23 +14,9 @@ else
nix profile install .#clan-app nix profile install .#clan-app
fi fi
# Check OS type
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
if ! command -v xdg-mime &> /dev/null; then # install desktop file
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed." set -eou pipefail
fi DESKTOP_FILE_NAME=org.clan.app.desktop
# install desktop file on Linux xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
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

View File

@@ -1,9 +0,0 @@
#!/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,8 +91,6 @@ 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,11 +37,6 @@ 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 (
<div <fieldset
role="group" role="group"
class={cx("fieldset", { inverted: props.inverted })} class={cx({ inverted: props.inverted })}
aria-disabled={props.disabled || undefined} disabled={props.disabled || false}
> >
{props.legend && ( {props.legend && (
<legend> <legend>
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography> </Typography>
</div> </div>
)} )}
</div> </fieldset>
); );
}; };

View File

@@ -1,50 +0,0 @@
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,13 +1,6 @@
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
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";
@@ -21,15 +14,16 @@ 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;
onChange: (values: string[]) => void; input: ComponentProps<"select">;
defaultValue?: string[];
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
defaultValue?: string[];
defaultOptions?: string[]; defaultOptions?: string[];
readonlyOptions?: string[]; readonlyOptions?: string[];
}; };
@@ -50,42 +44,8 @@ const sortedOptions = (options: MachineTag[]) =>
const sortedAndUniqueOptions = (options: MachineTag[]) => const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options)); sortedOptions(uniqueOptions(options));
export const MachineTags = (props: MachineTagsProps) => { // customises how each option is displayed in the dropdown
const [local, rest] = splitProps(props, ["defaultValue"]); const ItemComponent =
// // convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(local.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([
...(props.readonlyOptions || []).map((value) => ({
value,
disabled: true,
})),
...(props.defaultOptions || []).map((value) => ({ value })),
]),
);
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> }) => { (inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return ( return (
<Combobox.Item <Combobox.Item
@@ -93,7 +53,6 @@ export const MachineTags = (props: MachineTagsProps) => {
class={cx(styles.listboxItem, { class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted, [styles.listboxItemInverted]: inverted,
})} })}
onClick={handleToggle(props.item)}
> >
<Combobox.ItemLabel> <Combobox.ItemLabel>
<Typography <Typography
@@ -112,7 +71,22 @@ export const MachineTags = (props: MachineTagsProps) => {
); );
}; };
let selectRef: HTMLSelectElement; export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(props.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([
...(props.readonlyOptions || []).map((value) => ({
value,
disabled: true,
})),
...(props.defaultOptions || []).map((value) => ({ value })),
]),
);
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
@@ -122,49 +96,22 @@ 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;
const trimmed = input.value.trim(); if (input.value === "") return;
if (!trimmed) return;
setAvailableOptions((curr) => { setAvailableOptions((options) => {
if (curr.find((option) => option.value === trimmed)) { return options.map((option) => {
return curr; return {
} ...option,
return [ new: undefined,
...curr, };
{
value: trimmed,
},
];
}); });
setSelectedOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
return [
...curr,
{
value: trimmed,
},
];
}); });
selectRef.dispatchEvent( // reset the input value
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";
@@ -179,16 +126,35 @@ 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="Start typing a name and press enter" placeholder="Enter a tag name"
onChange={() => { // triggerMode="focus"
// noop, we handle this via the selectedOptions signal removeOnBackspace={false}
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()}>
@@ -198,12 +164,7 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props} {...props}
/> />
<Combobox.HiddenSelect <Combobox.HiddenSelect {...props.input} multiple />
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.Control<MachineTag> <Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)} class={cx(styles.control, props.orientation)}
@@ -226,13 +187,7 @@ export const MachineTags = (props: MachineTagsProps) => {
icon={"Close"} icon={"Close"}
size="0.5rem" size="0.5rem"
inverted={inverted} inverted={inverted}
onClick={() => onClick={() => state.remove(option)}
setSelectedOptions((curr) => {
return curr.filter(
(o) => o.value !== option.value,
);
})
}
/> />
) )
} }
@@ -265,6 +220,7 @@ 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,19 +76,6 @@ 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 {
@@ -114,7 +101,7 @@ div.form-field {
} }
& > .icon { & > .icon {
@apply w-[0.6875rem] h-[0.6875rem]; @apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
} }
} }
} }

View File

@@ -1,8 +1,6 @@
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">
@@ -85,38 +83,16 @@ export const Tooltip: Story = {
}, },
}; };
export const WithIcon: Story = { export const Icon: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />, icon: "Checkmark",
},
};
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: {
...WithIcon.args, ...Icon.args,
ghost: true, ghost: true,
}, },
}; };
@@ -130,14 +106,14 @@ export const Invalid: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
...WithIcon.args, ...Icon.args,
disabled: true, disabled: true,
}, },
}; };
export const ReadOnly: Story = { export const ReadOnly: Story = {
args: { args: {
...WithIcon.args, ...Icon.args,
readOnly: true, readOnly: true,
defaultValue: "14/05/02", defaultValue: "14/05/02",
}, },

View File

@@ -11,20 +11,12 @@ 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 { import { splitProps } from "solid-js";
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) => {
@@ -36,39 +28,6 @@ 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(
@@ -91,11 +50,6 @@ 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}
@@ -104,17 +58,9 @@ export const TextInput = (props: TextInputProps) => {
/> />
)} )}
<TextField.Input <TextField.Input
ref={inputRef}
{...props.input} {...props.input}
class={cx({ classList={{ "has-icon": props.icon && !props.readOnly }}
"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 size-full; @apply w-4 h-4;
&.primary { &.primary {
& > div.wrapper > div.parent, & > div.wrapper > div.parent,
@@ -15,18 +15,6 @@
background: #0051ff; background: #0051ff;
} }
} }
&.sizeDefault {
@apply size-4;
}
&.sizeLarge {
@apply size-8;
}
&.sizeExtraLarge {
@apply size-12;
}
} }
.wrapper { .wrapper {

View File

@@ -1,17 +1,9 @@
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: LoaderExamples, component: Loader,
}; };
export default meta; export default meta;

View File

@@ -7,23 +7,15 @@ 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,9 +20,6 @@ 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()}>
@@ -31,6 +28,9 @@ 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 rounded-b-md overflow-hidden; @apply w-60 border-none z-10 h-full flex flex-col;
} }

View File

@@ -1,15 +1,14 @@
div.sidebar-body { div.sidebar-body {
@apply py-4 px-2; @apply py-4 px-2 h-full;
/* 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(
@@ -21,14 +20,13 @@ div.sidebar-body {
@apply backdrop-blur-sm; @apply backdrop-blur-sm;
.accordion { .accordion {
@apply w-full mb-4 h-full flex flex-col justify-start gap-4; @apply w-full mb-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;
} }
@@ -60,13 +58,9 @@ div.sidebar-body {
} }
& > .content { & > .content {
@apply flex flex-col; @apply overflow-hidden 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,12 +5,11 @@ 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, buildServicePath } from "@/src/hooks/clan"; import { buildMachinePath, useClanURI } 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;
@@ -34,19 +33,19 @@ const MachineRoute = (props: MachineProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted inverted={true}
> >
{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 color="tertiary" /> <Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
<Typography <Typography
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="s" size="s"
inverted inverted={true}
color="primary" color="primary"
> >
{props.serviceCount} {props.serviceCount}
@@ -57,13 +56,18 @@ const MachineRoute = (props: MachineProps) => {
); );
}; };
const Machines = () => { export const SidebarBody = (props: SidebarProps) => {
const ctx = useClanContext(); const clanURI = useClanURI();
if (!ctx) {
throw new Error("ClanContext not found");
}
const clanURI = ctx.clanURI; const ctx = useClanContext();
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 = ["your-machines", ...sectionLabels];
const machines = () => { const machines = () => {
if (!ctx.machinesQuery.isSuccess) { if (!ctx.machinesQuery.isSuccess) {
@@ -75,7 +79,13 @@ const Machines = () => {
}; };
return ( return (
<Accordion.Item class="item" value="machines"> <div class="sidebar-body">
<Accordion
class="accordion"
multiple
defaultValue={defaultAccordionValues}
>
<Accordion.Item class="item" value="your-machines">
<Accordion.Header class="header"> <Accordion.Header class="header">
<Accordion.Trigger class="trigger"> <Accordion.Trigger class="trigger">
<Typography <Typography
@@ -83,12 +93,17 @@ const Machines = () => {
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="xs" size="xs"
inverted inverted={true}
color="tertiary" color="tertiary"
> >
Your Machines Your Machines
</Typography> </Typography>
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" /> <Icon
icon="CaretDown"
color="tertiary"
inverted={true}
size="0.75rem"
/>
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="content"> <Accordion.Content class="content">
@@ -96,7 +111,12 @@ const Machines = () => {
when={machines()} when={machines()}
fallback={ fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5"> <div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography hierarchy="body" size="s" weight="medium" inverted> <Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
No machines yet No machines yet
</Typography> </Typography>
<Button <Button
@@ -116,8 +136,8 @@ const Machines = () => {
<MachineRoute <MachineRoute
clanURI={clanURI} clanURI={clanURI}
machineID={id} machineID={id}
name={machine.data.name || id} name={machine.name || id}
serviceCount={machine?.instance_refs?.length ?? 0} serviceCount={0}
/> />
)} )}
</For> </For>
@@ -125,131 +145,6 @@ const Machines = () => {
</Show> </Show>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </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 (
<div class="sidebar-body">
<Accordion
class="accordion"
multiple
defaultValue={defaultAccordionValues}
>
<Machines />
<Services />
<For each={props.staticSections}> <For each={props.staticSections}>
{(section) => ( {(section) => (
@@ -261,7 +156,7 @@ export const SidebarBody = (props: SidebarProps) => {
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="xs" size="xs"
inverted inverted={true}
color="tertiary" color="tertiary"
> >
{section.title} {section.title}
@@ -269,7 +164,7 @@ export const SidebarBody = (props: SidebarProps) => {
<Icon <Icon
icon="CaretDown" icon="CaretDown"
color="tertiary" color="tertiary"
inverted inverted={true}
size="0.75rem" size="0.75rem"
/> />
</Accordion.Trigger> </Accordion.Trigger>
@@ -284,7 +179,7 @@ export const SidebarBody = (props: SidebarProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted inverted={true}
> >
{link.label} {link.label}
</Typography> </Typography>

View File

@@ -13,7 +13,6 @@ 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>;
@@ -138,21 +137,18 @@ export const Default: Story = {
console.log("saving tags", values); console.log("saving tags", values);
}} }}
> >
{({ editing, Field, formStore }) => ( {({ editing, Field }) => (
<Field name="tags" type="string[]"> <Field name="tags" type="string[]">
{(field, props) => ( {(field, input) => (
<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,7 +2,6 @@ import { createSignal, JSX, Show } from "solid-js";
import { import {
createForm, createForm,
FieldValues, FieldValues,
FormStore,
getErrors, getErrors,
Maybe, Maybe,
PartialValues, PartialValues,
@@ -26,7 +25,6 @@ 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;
} }
@@ -53,8 +51,6 @@ 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);
}; };
@@ -113,7 +109,7 @@ export function SidebarSectionForm<
</Typography> </Typography>
</div> </div>
</Show> </Show>
{props.children({ editing: editing(), Field, formStore })} {props.children({ editing: editing(), Field })}
</div> </div>
</div> </div>
</Form> </Form>

View File

@@ -5,7 +5,6 @@ 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;
@@ -13,8 +12,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 (
@@ -33,12 +32,7 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
<InstallModal <InstallModal
open={showInstall()} open={showInstall()}
machineName={useMachineName()} machineName={useMachineName()}
onClose={async () => { onClose={() => setShowModal(false)}
// refresh some queries
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
setShowModal(false);
}}
/> />
</Show> </Show>
</div> </div>

View File

@@ -1,46 +0,0 @@
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, useSearchParams } from "@solidjs/router"; import { Params, Navigator, useParams } 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,47 +30,6 @@ 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);
@@ -105,21 +64,7 @@ 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,9 +25,6 @@ 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;
@@ -50,7 +47,7 @@ export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ListMachines>(() => ({ return useQuery<ListMachines>(() => ({
queryKey: [...clanKey(clanURI), "machines"], queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => { queryFn: async () => {
const api = client.fetch("list_machines", { const api = client.fetch("list_machines", {
flake: { flake: {
@@ -67,16 +64,10 @@ 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: [machineKey(clanURI, machineName)], queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => { queryFn: async () => {
const [tagsCall, machineCall, schemaCall] = [ const [tagsCall, machineCall, schemaCall] = [
client.fetch("list_tags", { client.fetch("list_tags", {
@@ -131,7 +122,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: [...clanKey(clanURI), "tags"], queryKey: ["clans", encodeBase64(clanURI), "tags"],
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("list_tags", { const apiCall = client.fetch("list_tags", {
flake: { flake: {
@@ -151,7 +142,8 @@ 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: [...machineKey(clanURI, machineName), "state"], queryKey: ["clans", encodeBase64(clanURI), "machine", 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: {
@@ -174,61 +166,13 @@ 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: [machineKey(clanURI, machineName), "details"], queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_machine_details", { const call = client.fetch("get_machine_details", {
machine: { machine: {
@@ -258,7 +202,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: [...clanKey(clanURI), "details"], queryKey: ["clans", encodeBase64(clanURI), "details"],
persister: ClanDetailsPersister.persisterFn, persister: ClanDetailsPersister.persisterFn,
queryFn: async () => { queryFn: async () => {
const args = { const args = {
@@ -309,8 +253,7 @@ export const useClanListQuery = (
return useQueries(() => ({ return useQueries(() => ({
queries: clanURIs.map((clanURI) => { queries: clanURIs.map((clanURI) => {
// @BMG: Is duplicating query key intentional? const queryKey = ["clans", encodeBase64(clanURI), "details"];
const queryKey = [...clanKey(clanURI), "details"];
return { return {
// eslint-disable-next-line @tanstack/query/exhaustive-deps // eslint-disable-next-line @tanstack/query/exhaustive-deps
@@ -379,7 +322,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: ["flash_options"], queryKey: ["clans", "machine_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;
@@ -543,7 +486,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: [...clanKey(clanUri), "service_modules"], queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("list_service_modules", { const call = client.fetch("list_service_modules", {
flake: { flake: {
@@ -563,14 +506,12 @@ 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: [...clanKey(clanUri), "service_instances"], queryKey: ["clans", encodeBase64(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, useLocation, useNavigate } from "@solidjs/router"; import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
createContext, createContext,
@@ -17,15 +17,13 @@ import {
useClanURI, useClanURI,
useMachineName, useMachineName,
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes"; import { CubeScene, setWorldMode, worldMode } 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";
@@ -35,27 +33,37 @@ 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";
export type WorldMode = "default" | "select" | "service" | "create" | "move"; interface ClanContextProps {
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, serviceInstancesQuery]; const allQueries = [machinesQuery, ...allClansQueries];
return { return {
clanURI, clanURI,
@@ -63,23 +71,14 @@ 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< const ClanContext = createContext<ClanContextProps>();
ReturnType<typeof createClanContext> | undefined
>();
export const useClanContext = () => { export const useClanContext = () => {
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
@@ -105,14 +104,12 @@ 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 (
@@ -135,6 +132,8 @@ 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;
@@ -195,7 +194,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
}), }),
); );
const location = useLocation(); const client = useApiClient();
const handleSubmitService: SubmitServiceHandler = async (
instance,
action,
) => {
console.log(action, "Instance", instance);
if (action !== "create") {
toast.error("Only creating new services is supported");
return;
}
const call = client.fetch("create_service_instance", {
flake: {
identifier: ctx.clanURI,
},
module_ref: instance.module,
roles: instance.roles,
});
const result = await call.result;
if (result.status === "error") {
toast.error("Error creating service instance");
console.error("Error creating service instance", result.errors);
}
toast.success("Created");
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 (
<> <>
@@ -231,19 +268,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
isLoading={ctx.isLoading()} isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery} cubesQuery={ctx.machinesQuery}
toolbarPopup={ toolbarPopup={
<Show when={ctx.worldMode() === "service"}> <Show when={showService()}>
<Show <ServiceWorkflow
when={location.pathname.includes("/services/")} handleSubmit={handleSubmitService}
fallback={
<SelectService
onClose={() => { onClose={() => {
ctx.setWorldMode("select"); setShowService(false);
setWorldMode("select");
currentPromise()?.resolve({ id: "0" });
}} }}
/> />
}
>
{props.children}
</Show>
</Show> </Show>
} }
onCreate={onCreate} onCreate={onCreate}

View File

@@ -6,11 +6,10 @@ 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();
@@ -21,16 +20,13 @@ 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,
@@ -61,13 +57,8 @@ 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 />
</> </>
); );
}; };
@@ -78,19 +69,16 @@ export const Machine = (props: RouteSectionProps) => {
<SidebarPane <SidebarPane
title={useMachineName()} title={useMachineName()}
onClose={onClose} onClose={onClose}
// the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and subHeader={
// present the user with the ability to install or update a machines based on `installedAt` in the inventory.json <Show when={useMachineName()} keyed>
// <SidebarMachineStatus
// subHeader={ clanURI={clanURI}
// <Show when={useMachineName()} keyed> machineName={useMachineName()}
// <SidebarMachineStatus />
// clanURI={clanURI} </Show>
// machineName={useMachineName()} }
// />
// </Show>
// }
> >
{Sections()} {sections()}
</SidebarPane> </SidebarPane>
</div> </div>
</Show> </Show>

View File

@@ -1,41 +0,0 @@
.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

@@ -1,53 +0,0 @@
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,7 +5,6 @@ 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()))),
@@ -33,7 +32,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
@@ -59,7 +58,7 @@ export const SectionTags = (props: SectionTags) => {
onSubmit={props.onSubmit} onSubmit={props.onSubmit}
initialValues={initialValues()} initialValues={initialValues()}
> >
{({ editing, Field, formStore }) => ( {({ editing, Field }) => (
<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) => (
@@ -73,10 +72,7 @@ export const SectionTags = (props: SectionTags) => {
defaultValue={field.value} defaultValue={field.value}
defaultOptions={options()[0]} defaultOptions={options()[0]}
readonlyOptions={options()[1]} readonlyOptions={options()[1]}
onChange={(newVal) => { input={input}
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
/> />
)} )}
</Field> </Field>

View File

@@ -1,59 +0,0 @@
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,7 +2,6 @@ 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[] = [
{ {
@@ -31,15 +30,6 @@ 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,10 +1,11 @@
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";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js"; // @ts-expect-error: No types for troika-three-text
import { FontLoader } from "three/examples/jsm/Addons"; import { Text } from "troika-three-text";
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json"; import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
// Constants // Constants
const BASE_SIZE = 0.9; const BASE_SIZE = 0.9;
@@ -22,71 +23,6 @@ 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;
@@ -110,21 +46,31 @@ 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,
});
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh(); this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
this.cubeMesh = cubeMesh; this.cubeMesh.castShadow = true;
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 = baseMesh; this.baseMesh = this.createCubeBase(
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, color: BASE_COLOR, // any color you like
roughness: 1, roughness: 1,
metalness: 0, metalness: 0,
transparent: true, transparent: true,
@@ -158,6 +104,8 @@ 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,
@@ -174,6 +122,9 @@ 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();
}, },
@@ -198,85 +149,45 @@ export class MachineRepr {
renderLoop.requestRender(); renderLoop.requestRender();
} }
private createLabel(id: string) { private createCubeBase(
const group = new THREE.Group(); color: THREE.ColorRepresentation,
// 0x162324 emissive: THREE.ColorRepresentation,
// const text = new Text(); geometry: THREE.BoxGeometry,
// text.text = id; ) {
// text.font = ttf; const baseMaterial = new THREE.MeshPhongMaterial({
// text.fontSize = 0.1; color,
// text.color = 0xffffff; emissive,
// text.anchorX = "center"; transparent: true,
// text.anchorY = "middle"; opacity: 1,
// text.position.set(0, 0, 0.01);
// text.outlineWidth = 0.005;
// text.outlineColor = 0x162324;
// text.sync(() => {
// renderLoop.requestRender();
// });
const textMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
}); });
const textGeo = new TextGeometry(id, { const base = new THREE.Mesh(geometry, baseMaterial);
font: new FontLoader().parse(jsonfont), base.position.set(0, BASE_HEIGHT / 2, 0);
size: 0.09, base.receiveShadow = false;
depth: 0.001, return base;
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) --- private createLabel(id: string) {
const padding = 0.04; const text = new Text();
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1; text.text = id;
const bgWidth = textWidth + 10 * padding; text.font = ttf;
// const bgWidth = text.text.length * 0.07 + padding; // text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
const bgHeight = 0.1 + 2 * padding; text.fontSize = 0.15; // relative to your cube size
const radius = 0.02; text.color = 0x000000; // any THREE.Color
text.anchorX = "center"; // horizontal centering
text.anchorY = "bottom"; // baseline aligns to cube top
text.position.set(0, CUBE_SIZE + 0.05, 0);
const bgShape = roundedRectShape(bgWidth, bgHeight, radius); // If you want it to always face camera:
const bgGeom = new THREE.ShapeGeometry(bgShape); text.userData.isLabel = true;
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 }); text.outlineWidth = 0.005;
const bg = new THREE.Mesh(bgGeom, bgMat); text.outlineColor = 0x333333;
bg.position.set(0, 0, -0.01); text.quaternion.copy(this.camera.quaternion);
// --- Arrow (triangle pointing down) --- // Re-render on text changes
const arrowShape = new THREE.Shape(); text.sync(() => {
arrowShape.moveTo(-0.05, 0); renderLoop.requestRender();
arrowShape.lineTo(0.05, 0); });
arrowShape.lineTo(0, -0.05); return text;
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) {
@@ -286,13 +197,12 @@ 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-30 left-1/2; @apply absolute bottom-10 z-10 w-full;
@apply flex justify-center items-center; @apply flex justify-center items-center;
} }

View File

@@ -25,13 +25,7 @@ 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 { import { clearHighlight, setHighlightGroups } from "./highlightStore";
clearHighlight,
highlightGroups,
setHighlightGroups,
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
function intersectMachines( function intersectMachines(
event: MouseEvent, event: MouseEvent,
@@ -39,7 +33,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,
@@ -50,10 +44,7 @@ function intersectMachines(
Array.from(machineManager.machines.values().map((m) => m.group)), Array.from(machineManager.machines.values().map((m) => m.group)),
); );
return { return intersects.map((i) => i.object.userData.id);
machines: intersects.map((i) => i.object.userData.id),
intersection: intersects,
};
} }
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
@@ -95,6 +86,12 @@ 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 }>;
@@ -106,8 +103,6 @@ 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;
@@ -118,7 +113,6 @@ 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();
@@ -129,17 +123,12 @@ 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({
@@ -311,12 +300,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);
@@ -395,23 +384,6 @@ 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);
@@ -437,7 +409,7 @@ export function CubeScene(props: {
updateCameraInfo(); updateCameraInfo();
createEffect( createEffect(
on(ctx.worldMode, (mode) => { on(worldMode, (mode) => {
if (mode === "create") { if (mode === "create") {
actionBase!.visible = true; actionBase!.visible = true;
} else { } else {
@@ -449,7 +421,7 @@ export function CubeScene(props: {
const registry = new ObjectRegistry(); const registry = new ObjectRegistry();
machineManager = new MachineManager( const machineManager = new MachineManager(
scene, scene,
registry, registry,
props.sceneStore, props.sceneStore,
@@ -463,7 +435,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 (ctx.worldMode() === "create") { if (worldMode() === "create") {
props props
.onCreate() .onCreate()
.then(({ id }) => { .then(({ id }) => {
@@ -481,16 +453,17 @@ export function CubeScene(props: {
.finally(() => { .finally(() => {
if (actionBase) actionBase.visible = false; if (actionBase) actionBase.visible = false;
ctx.setWorldMode("select"); setWorldMode("default");
}); });
} }
if (ctx.worldMode() === "move") { if (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);
ctx.setWorldMode("select"); setWorldMode("select");
clearHighlight("move"); clearHighlight("move");
} }
@@ -504,20 +477,18 @@ 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) {
const id = intersects.find((i) => i.object.userData?.id)?.object console.log("Clicked on cube:", intersects);
.userData.id; const id = intersects[0].object.userData.id;
if (!id) return; if (worldMode() === "select") props.onSelect(new Set<string>([id]));
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 (ctx.worldMode() === "select") props.onSelect(new Set<string>()); if (worldMode() === "select") props.onSelect(new Set<string>());
} }
}; };
@@ -549,73 +520,24 @@ export function CubeScene(props: {
}; };
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
const { machines, intersection } = intersectMachines( if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
const intersection = intersectMachines(
e, e,
renderer, renderer,
camera, camera,
machineManager, machineManager,
raycaster, 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) {
e.preventDefault();
e.stopPropagation();
if (!intersection.length) return; if (!intersection.length) return;
setMenuIntersection(machines); setMenuIntersection(intersection);
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);
@@ -660,55 +582,21 @@ 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");
ctx.setWorldMode("create"); setWorldMode("create");
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const onMouseMove = (event: MouseEvent) => { const onMouseMove = (event: MouseEvent) => {
if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return; if (!(worldMode() === "create" || worldMode() === "move")) return;
if (!actionBase) return;
const actionRepr = console.log("Mouse move in create/move mode");
ctx.worldMode() === "create" ? actionBase : actionMachine;
if (!actionRepr) return;
actionRepr.visible = true; actionBase.visible = true;
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set( (actionBase.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
@@ -723,45 +611,41 @@ export function CubeScene(props: {
if (intersects.length > 0) { if (intersects.length > 0) {
const point = intersects[0].point; const point = intersects[0].point;
const snapped = snapToGrid(point); // Snap to grid
if (!snapped) return; 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
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(actionRepr.position.x - snapped.x) > 0.01 || Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
Math.abs(actionRepr.position.z - snapped.z) > 0.01 Math.abs(actionBase.position.z - snapped.z) > 0.01
) { ) {
// Only request render if the position actually changed // Only request render if the position actually changed
actionRepr.position.set(snapped.x, 0, snapped.z); actionBase.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") => {
ctx.setWorldMode(mode); 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 (
@@ -780,10 +664,10 @@ export function CubeScene(props: {
<div <div
class={cx( class={cx(
"cubes-scene-container", "cubes-scene-container",
ctx.worldMode() === "default" && "cursor-no-drop", worldMode() === "default" && "cursor-no-drop",
ctx.worldMode() === "select" && "cursor-pointer", worldMode() === "select" && "cursor-pointer",
ctx.worldMode() === "service" && "cursor-pointer", worldMode() === "service" && "cursor-pointer",
ctx.worldMode() === "create" && "cursor-cell", worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing", isDragging() && "!cursor-grabbing",
)} )}
ref={(el) => (container = el)} ref={(el) => (container = el)}
@@ -797,25 +681,24 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => ctx.setWorldMode("select")} onClick={() => setWorldMode("select")}
selected={ctx.worldMode() === "select"} selected={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={ctx.worldMode() === "create"} selected={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={ctx.worldMode() === "service"} selected={worldMode() === "service"}
onClick={() => { onClick={() => {
ctx.navigateToRoot(); setWorldMode("service");
ctx.setWorldMode("service");
}} }}
/> />
<ToolbarButton <ToolbarButton

View File

@@ -6,35 +6,3 @@ 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,21 +26,15 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
const resultData: Partial<ResultDataMap> = { const resultData: Partial<ResultDataMap> = {
list_machines: { list_machines: {
pandora: { pandora: {
data: {
name: "pandora", name: "pandora",
}, },
},
enceladus: { enceladus: {
data: {
name: "enceladus", name: "enceladus",
}, },
},
dione: { dione: {
data: {
name: "dione", name: "dione",
}, },
}, },
},
}; };
return { return {

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,7 +111,10 @@ export const AddMachine = (props: AddMachineProps) => {
return defaultClass; return defaultClass;
} }
switch (currentStep.id) {
default:
return defaultClass; return defaultClass;
}
}; };
return ( return (

View File

@@ -22,7 +22,7 @@ export const StepProgress = (props: StepProgressProps) => {
when={store.error} when={store.error}
fallback={ fallback={
<> <>
<Loader size="l" /> <Loader class="size-8" />
<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,12 +1,7 @@
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 { import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
createForm,
setValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { import {
AddMachineSteps, AddMachineSteps,
AddMachineStoreType, AddMachineStoreType,
@@ -16,7 +11,6 @@ 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()),
@@ -42,20 +36,16 @@ 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,
},
}, },
}); });
@@ -88,12 +78,9 @@ export const StepTags = (props: { onDone: () => void }) => {
{...field} {...field}
required required
orientation="horizontal" orientation="horizontal"
defaultValue={field.value || []} defaultValue={field.value}
defaultOptions={[]} defaultOptions={[]}
onChange={(newVal) => { input={input}
// 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: "hidden", prompt_type: "line",
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: "hidden", prompt_type: "line",
display: { display: {
helperText: null, helperText: null,
label: "(5) Password", label: "(5) Password",

View File

@@ -51,13 +51,12 @@ 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" | "run_machine_update">; progress: ApiCall<"run_machine_install">;
promptValues: PromptValues; promptValues: PromptValues;
prepareStep: "disk" | "generators" | "install"; prepareStep: "disk" | "generators" | "install";
}; };
@@ -107,23 +106,22 @@ 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={onClose} /> <InstallStepper onDone={() => props.onClose} />
</Modal> </Modal>
</StepperProvider> </StepperProvider>
); );

View File

@@ -1,198 +0,0 @@
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

@@ -1,304 +0,0 @@
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, AlertProps } from "@/src/components/Alert/Alert"; import { Alert } 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,8 +34,6 @@ 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 (
@@ -56,16 +54,11 @@ 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>;
export const ConfigureAddress = (props: { const ConfigureAddress = () => {
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);
@@ -91,11 +84,10 @@ export const ConfigureAddress = (props: {
...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 () => {
@@ -106,14 +98,12 @@ export const ConfigureAddress = (props: {
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",
@@ -134,14 +124,13 @@ export const ConfigureAddress = (props: {
<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 machine" description="Hostname of the installation target"
value={field.value} value={field.value}
required required
orientation="horizontal" orientation="horizontal"
@@ -174,24 +163,6 @@ export const ConfigureAddress = (props: {
/> />
)} )}
</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>
} }
@@ -204,9 +175,7 @@ export const ConfigureAddress = (props: {
!isReachable() || !isReachable() ||
isReachable() !== getValue(formStore, "targetHost") isReachable() !== getValue(formStore, "targetHost")
} }
fallback={ fallback={<NextButton type="submit">Next</NextButton>}
<NextButton type="submit">{props.next || "next"}</NextButton>
}
> >
<Button <Button
endIcon="ArrowRight" endIcon="ArrowRight"
@@ -243,14 +212,6 @@ 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
@@ -262,8 +223,7 @@ 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",
@@ -426,7 +386,7 @@ const ConfigureDisk = () => {
); );
}; };
export const ConfigureData = () => { const ConfigureData = () => {
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal); const [store, get] = getStepStore<InstallStoreType>(stepSignal);
@@ -438,22 +398,7 @@ export const ConfigureData = () => {
return ( return (
<> <>
<Show when={generatorsQuery.isLoading}> <Show when={generatorsQuery.isLoading}>
<div class="relative flex w-full flex-col items-center justify-end "> Checking credentials & data...
<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()} />}
@@ -555,7 +500,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
}; };
return ( return (
<Form onSubmit={handleSubmit} class="h-full"> <Form onSubmit={handleSubmit}>
<StepLayout <StepLayout
body={ body={
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -567,45 +512,13 @@ const PromptsFields = (props: PromptsFieldsProps) => {
<Field <Field
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`} name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
> >
{(f, props) => { {(f, props) => (
const defaultInputType =
fieldInfo.prompt.prompt_type.includes("hidden")
? "password"
: "text";
const [inputType, setInputType] =
createSignal(defaultInputType);
return (
<TextInput <TextInput
{...f} {...f}
label={ label={
fieldInfo.prompt.display?.label || fieldInfo.prompt.display?.label ||
fieldInfo.prompt.name fieldInfo.prompt.name
} }
endComponent={(local) => (
<Show when={defaultInputType === "password"}>
<KButton
onClick={() => {
setInputType((type) =>
type === "password"
? "text"
: "password",
);
}}
>
<Icon
icon={
inputType() == "password"
? "EyeClose"
: "EyeOpen"
}
color="quaternary"
inverted={local.inverted}
/>
</KButton>
</Show>
)}
description={fieldInfo.prompt.description} description={fieldInfo.prompt.description}
value={f.value || fieldInfo.value || ""} value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required} required={fieldInfo.prompt.display?.required}
@@ -619,12 +532,15 @@ const PromptsFields = (props: PromptsFieldsProps) => {
: "valid" : "valid"
} }
input={{ input={{
type: inputType(), type: fieldInfo.prompt.prompt_type.includes(
"hidden",
)
? "password"
: "text",
...props, ...props,
}} }}
/> />
); )}
}}
</Field> </Field>
)} )}
</For> </For>
@@ -644,7 +560,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">
@@ -667,15 +583,7 @@ 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", {
@@ -741,8 +649,7 @@ 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",
@@ -786,7 +693,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

@@ -1,120 +0,0 @@
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,30 +62,22 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
}, },
list_machines: { list_machines: {
jon: { jon: {
data: {
name: "jon", name: "jon",
tags: ["all", "nixos", "tag1"], tags: ["all", "nixos", "tag1"],
}, },
},
sara: { sara: {
data: {
name: "sara", name: "sara",
tags: ["all", "darwin", "tag2"], tags: ["all", "darwin", "tag2"],
}, },
},
kyra: { kyra: {
data: {
name: "kyra", name: "kyra",
tags: ["all", "darwin", "tag2"], tags: ["all", "darwin", "tag2"],
}, },
},
leila: { leila: {
data: {
name: "leila", name: "leila",
tags: ["all", "darwin", "tag2"], tags: ["all", "darwin", "tag2"],
}, },
}, },
},
list_tags: { list_tags: {
options: ["desktop", "server", "full", "only", "streaming", "backup"], options: ["desktop", "server", "full", "only", "streaming", "backup"],
special: ["all", "nixos", "darwin"], special: ["all", "nixos", "darwin"],
@@ -160,9 +152,6 @@ 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,9 +4,10 @@ import {
StepperProvider, StepperProvider,
useStepper, useStepper,
} from "@/src/hooks/stepper"; } from "@/src/hooks/stepper";
import { useClanURI, useServiceParams } from "@/src/hooks/clan"; import { useClanURI } from "@/src/hooks/clan";
import { import {
MachinesQuery, MachinesQuery,
ServiceModules,
TagsQuery, TagsQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstances, useServiceInstances,
@@ -17,15 +18,18 @@ 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";
@@ -36,16 +40,152 @@ 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 { import { useClickOutside } from "@/src/hooks/useClickOutside";
getRoleMembers,
RoleType, type ModuleItem = ServiceModules["modules"][number];
ServiceStoreType,
SubmitServiceHandler, interface Module {
} from "./models"; value: string;
import { TagSelect } from "@/src/components/Search/TagSelect"; label: string;
import { Tag } from "@/src/components/Tag/Tag"; raw: ModuleItem;
}
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[]>(() => {
@@ -66,7 +206,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.data.tags?.includes(tag)) .filter(([_, v]) => v.tags?.includes(tag))
.map(([k]) => k), .map(([k]) => k),
})); }));
@@ -75,86 +215,22 @@ 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: routerProps.id, instanceName: store.module.name,
}, },
}); });
const selectedModule = createMemo(() => { const machinesQuery = useMachinesQuery(useClanURI());
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);
@@ -173,15 +249,13 @@ const ConfigureService = () => {
}, },
]), ]),
); );
store.handleSubmit( store.handleSubmit(
{ {
name: values.instanceName, name: values.instanceName,
module: { module: {
name: routerProps.name, name: store.module.name,
input: sanitizeModuleInput( input: store.module.input,
routerProps.input,
serviceModulesQuery.data?.core_input_name || "clan-core",
),
}, },
roles, roles,
}, },
@@ -197,7 +271,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>
{routerProps.name} {store.module.name}
</Typography> </Typography>
<Field name="instanceName"> <Field name="instanceName">
{(field, input) => ( {(field, input) => (
@@ -220,19 +294,16 @@ 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}>
<Show <For each={Object.keys(store.module.raw?.info.roles || {})}>
when={serviceModulesQuery.data && store.roles}
fallback={<div>Loading...</div>}
>
<For each={currentModuleRoles()}>
{(role) => { {(role) => {
const values = store.roles?.[role] || [];
return ( return (
<TagSelect<TagType> <TagSelect<TagType>
label={role.role} label={role}
renderItem={(item: TagType) => ( renderItem={(item: TagType) => (
<Tag <Tag
inverted inverted
@@ -247,43 +318,30 @@ const ConfigureService = () => {
{item.label} {item.label}
</Tag> </Tag>
)} )}
values={role.members} values={values}
options={options()} options={options()}
onClick={() => { onClick={() => {
set("currentRole", role.role); set("currentRole", role);
stepper.next(); stepper.next();
}} }}
/> />
); );
}} }}
</For> </For>
</Show>
</div> </div>
<div class={cx(styles.footer, styles.backgroundAlt)}> <div class={cx(styles.footer, styles.backgroundAlt)}>
<Button <BackButton ghost hierarchy="primary" class="mr-auto" />
hierarchy="secondary"
type="submit" <Button hierarchy="secondary" type="submit">
loading={!serviceInstancesQuery.data} <Show when={store.action === "create"}>Add Service</Show>
> <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>
); );
}; };
export type TagType = type TagType =
| { | {
value: string; value: string;
label: string; label: string;
@@ -304,36 +362,31 @@ 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(() => {
on(members, (m) => { console.log("Current role", store.currentRole, members());
clearAllHighlights(); clearAllHighlights();
setHighlightGroups({ setHighlightGroups({
[store.currentRole as string]: new Set( [store.currentRole as string]: new Set(
m.flatMap((m) => { members().flatMap((m) => {
if (m.type === "machine") return m.label; if (m.type === "machine") return m.label;
return m.members; return m.members;
}), }),
), ),
}); });
}),
);
onMount(() => { console.log("now", highlightGroups);
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);
@@ -350,6 +403,7 @@ const ConfigureRole = () => {
}), }),
); );
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI()); const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
@@ -374,7 +428,12 @@ 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 ghost size="xs" hierarchy="primary" /> <BackButton
ghost
size="xs"
hierarchy="primary"
// onClick={() => clearAllHighlights()}
/>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="s" size="s"
@@ -446,6 +505,10 @@ const ConfigureRole = () => {
}; };
const steps = [ const steps = [
{
id: "select:service",
content: SelectService,
},
{ {
id: "view:members", id: "view:members",
content: ConfigureService, content: ConfigureService,
@@ -459,34 +522,79 @@ 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 || "view:members", initialStep: props.initialStep || "select:service",
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 class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"> <div
ref={(e) => (ref = e)}
id="add-service"
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
{...props.rootProps}
>
<StepperProvider stepper={stepper}> <StepperProvider stepper={stepper}>
<div class="w-[30rem]">{stepper.currentStep().content()}</div> <div class="w-[30rem]">{stepper.currentStep().content()}</div>
</StepperProvider> </StepperProvider>

View File

@@ -1,83 +0,0 @@
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,10 +24,19 @@ clangStdenv.mkDerivation {
domain = "git.clan.lol"; domain = "git.clan.lol";
owner = "clan"; owner = "clan";
repo = "webview"; repo = "webview";
rev = "c27041cb50f79c197080a3f4fa2bad4557ef3234"; rev = "ef481aca8e531f6677258ca911c61aaaf71d2214";
hash = "sha256-xNkX7O+GFMbv3YnXPrtO6vw+BUqCbVeFd8FjgPKfEG0="; hash = "sha256-KF9ESpo40z6VXyYsZCLWJAIh0RFe1Zy/Qw4k7cTpoYU=";
}; };
# @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,9 +103,7 @@ 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( instantiate_inventory_to_machines(flake, machines_with_tags).values(),
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
@@ -130,13 +128,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:
machine = machines_with_tags.get(name) inventory_machine = machines_with_tags.get(name)
if not machine: if not inventory_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, machine.data), Machine.from_inventory(name, flake, inventory_machine),
) )
return machines_to_update return machines_to_update

View File

@@ -1,39 +0,0 @@
{
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,22 +431,20 @@ 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( assert m1_sops_store.machine_has_access(generator_m1, "my_shared_secret")
generator_m1, "my_shared_secret", "machine1" assert m2_sops_store.machine_has_access(generator_m2, "my_shared_secret")
)
assert m2_sops_store.machine_has_access(
generator_m2, "my_shared_secret", "machine2"
)
@pytest.mark.with_core @pytest.mark.with_core
@@ -501,7 +499,6 @@ 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,
@@ -747,74 +744,6 @@ 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,
@@ -887,8 +816,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", "machine1") assert sops_store_1.machine_has_access(generator_m1, "my_secret")
assert sops_store_2.machine_has_access(generator_m2, "my_secret", "machine2") assert sops_store_2.machine_has_access(generator_m2, "my_secret")
@pytest.mark.with_core @pytest.mark.with_core

View File

@@ -42,7 +42,11 @@ 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:
return "__shared" # Shared generators don't need a machine for most operations
# 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
@@ -58,7 +62,6 @@ 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"""
@@ -137,20 +140,16 @@ 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
prev_generator = dataclasses.replace( if self.exists(
generator, gen := dataclasses.replace(generator, share=not generator.share), var.name
share=not generator.share, ):
machine=machine if generator.share else None, changed_files += self.delete(gen, var.name)
)
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:
@@ -162,7 +161,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, machine) new_file = self._set(generator, var, value)
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:
@@ -170,8 +169,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_obj = Machine(name=generator.machine, flake=self.flake) machine = Machine(name=generator.machine, flake=self.flake)
log_info = machine_obj.info log_info = machine.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,6 +15,7 @@ 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
@@ -59,12 +60,9 @@ 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:
@@ -73,28 +71,20 @@ class Generator:
def __hash__(self) -> int: def __hash__(self) -> int:
return hash(self.key) return hash(self.key)
@property @cached_property
def exists(self) -> bool: def exists(self) -> bool:
"""Check if all files for this generator exist in their respective stores.""" if self.machine is None:
if self._public_store is None or self._secret_store is None: msg = "Machine cannot be None"
msg = "Stores must be set to check existence"
raise ClanError(msg) raise ClanError(msg)
if self._flake is None:
# Check if all files exist msg = "Flake cannot be None"
for file in self.files: raise ClanError(msg)
store = self._secret_store if file.secret else self._public_store return check_vars(self.machine, self._flake, generator_name=self.name)
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: Iterable[str], machine_names: list[str],
flake: "Flake", flake: "Flake",
include_previous_values: bool = False, include_previous_values: bool = False,
) -> list["Generator"]: ) -> list["Generator"]:
@@ -112,7 +102,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,validationHash}" generators_selector = "config.clan.core.vars.generators.*.{share,dependencies,migrateFact,prompts}"
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
@@ -133,7 +123,7 @@ class Generator:
generators_selector, generators_selector,
) )
if not generators_data: if not generators_data:
continue return []
# Get all file metadata in one select # Get all file metadata in one select
files_data = flake.select_machine( files_data = flake.select_machine(
@@ -172,30 +162,18 @@ 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=share, share=gen_data["share"],
files=files, files=files,
dependencies=[ dependencies=[
GeneratorKey( GeneratorKey(machine=machine_name, name=dep)
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,
# only set machine for machine-specific generators machine=machine_name,
# 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)
@@ -226,10 +204,14 @@ 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, machine: "Machine") -> Path: def final_script(self) -> 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',
@@ -240,7 +222,16 @@ class Generator:
return output return output
def validation(self) -> str | None: def validation(self) -> str | None:
return self.validation_hash if self.machine is None:
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,
@@ -263,6 +254,11 @@ 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(
@@ -394,7 +390,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(machine) final_script = self.final_script()
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)
@@ -434,7 +430,6 @@ class Generator:
self, self,
file, file,
secret_file.read_bytes(), secret_file.read_bytes(),
machine.name,
) )
secret_changed = True secret_changed = True
else: else:
@@ -442,7 +437,6 @@ 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,5 +1,3 @@
from unittest.mock import Mock
from clan_cli.vars.generator import ( from clan_cli.vars.generator import (
Generator, Generator,
GeneratorKey, GeneratorKey,
@@ -11,69 +9,30 @@ 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( gen_1 = Generator(name="gen_1", dependencies=[], machine=machine_name)
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]
} }
@@ -108,10 +67,6 @@ 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"
@@ -119,37 +74,35 @@ 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_keys(all_missing_closure(generators.keys(), generators)) == { assert generator_names(all_missing_closure(generators.keys(), generators)) == [
GeneratorKey(name="shared_gen", machine=None), "shared_gen",
GeneratorKey(name="gen_1", machine=machine_1), "gen_1",
GeneratorKey(name="gen_2", machine=machine_2), "gen_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_keys(requested_closure([shared_gen.key], generators)) == { assert generator_names(requested_closure([shared_gen.key], generators)) == [
GeneratorKey(name="shared_gen", machine=None), "shared_gen",
GeneratorKey(name="gen_1", machine=machine_1), "gen_1",
GeneratorKey(name="gen_2", machine=machine_2), "gen_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, machine.name, is_migration=True generator, file, old_value, 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, machine.name, is_migration=True generator, file, old_value, is_migration=True
) )
paths.extend(paths_list) paths.extend(paths_list)

View File

@@ -27,7 +27,6 @@ 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,7 +27,6 @@ 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)

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