Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes Kirschbauer
8584f3247a machine/install: fix type issues 2025-08-29 11:03:24 +02:00
Sacha Korban
d3534a2b72 fix: check if phases are non-default when running 2025-08-29 18:27:03 +10:00
153 changed files with 1679 additions and 5455 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,32 +0,0 @@
This service sets up a certificate authority (CA) that can issue certificates to
other machines in your clan. For this the `ca` role is used.
It additionally provides a `default` role, that can be applied to all machines
in your clan and will make sure they trust your CA.
## Example Usage
The following configuration would add a CA for the top level domain `.foo`. If
the machine `server` now hosts a webservice at `https://something.foo`, it will
get a certificate from `ca` which is valid inside your clan. The machine
`client` will trust this certificate if it makes a request to
`https://something.foo`.
This clan service can be combined with the `coredns` service for easy to deploy,
SSL secured clan-internal service hosting.
```nix
inventory = {
machines.ca = { };
machines.client = { };
machines.server = { };
instances."certificates" = {
module.name = "certificates";
module.input = "self";
roles.ca.machines.ca.settings.tlds = [ "foo" ];
roles.default.machines.client = { };
roles.default.machines.server = { };
};
};
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -1,108 +0,0 @@
# 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";
manifest.name = "clan-core/yggdrasil";
manifest.description = "Yggdrasil VPN";
roles.default = {
# interface =
# { 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
settings,
# The name of this instance of the service
instanceName,
# The current machine
machine,
# All roles of this service, with their assigned machines
roles,
...
}:
{
# 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 = {
files.privateKey = { };
runtimeInputs = with pkgs; [
yggdrasil
jq
];
script = ''
yggdrasil -genconf -json | jq 'to_entries|map(select(.key|endswith("Key")))|from_entries' > $out/privateKey
'';
};
services.yggdrasil = {
persistentKeys = true;
enable = true;
};
systemd.services.yggdrasil.serviceConfig.BindReadOnlyPaths = [
"${config.clan.core.vars.generators.yggdrasil.files.privateKey.path}:/var/lib/yggdrasil/keys.json"
];
# Interaction examples what you could do here:
# - Get some settings of this machine
# settings.ipRanges
#
# - Get all evening names:
# allEveningNames = lib.attrNames roles.evening.machines
#
# - Get all roles of the machine:
# machine.roles
#
# - Get the settings that where applied to a specific evening machine:
# roles.evening.machines.peer1.settings
# environment.etc.hello.text = "${settings.greeting} World!";
};
};
};
# 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,25 +0,0 @@
{
self,
inputs,
lib,
...
}:
let
module = lib.modules.importApply ./default.nix {
inherit (self) packages;
};
in
{
clan.modules = {
yggdrasil = module;
};
perSystem =
{ ... }:
{
clan.nixosTests.yggdrasil = {
imports = [ ./tests/vm/default.nix ];
clan.modules.yggdrasil = module;
};
};
}

View File

@@ -1,41 +0,0 @@
{
name = "yggdrasil";
clan = {
directory = ./.;
inventory = {
machines.peer1 = { };
# machines.peer2 = { };
instances."yggdrasil" = {
module.name = "yggdrasil";
module.input = "self";
# Assign the roles to the two machines
roles.default.machines.peer1 = { };
# roles.evening.machines.peer2 = {
# # Set roles settings for the peers, where we want to differ from
# # the role defaults
# settings = {
# greeting = "Good night";
# };
# };
};
};
};
testScript =
{ ... }:
''
start_all()
# value = peer1.succeed("greet-world")
# assert value.strip() == "Good morning World! I'm peer1", value
#
# value = peer2.succeed("greet-world")
# assert value.strip() == "Good night World! I'm peer2", value
'';
}

View File

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

18
devFlake/flake.lock generated
View File

@@ -84,11 +84,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1757195359, "lastModified": 1756400612,
"narHash": "sha256-Uf/d5NGvq+Q6ct+n5xRr76N1ZGV0vkfsJ6iVTciPkY0=", "narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f4cefbe0160ba99567be386a043824549ccd5cb7", "rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
"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": {
@@ -165,11 +165,11 @@
"nixpkgs": [] "nixpkgs": []
}, },
"locked": { "locked": {
"lastModified": 1756662192, "lastModified": 1755934250,
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

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

View File

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

38
flake.lock generated
View File

@@ -13,11 +13,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1756695982, "lastModified": 1756091210,
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=", "narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb", "rev": "eb831bca21476fa8f6df26cb39e076842634700d",
"type": "tarball", "type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/cc8f26e7e6c2dc985526ba59b286ae5a83168cdb.tar.gz" "url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/eb831bca21476fa8f6df26cb39e076842634700d.tar.gz"
}, },
"original": { "original": {
"type": "tarball", "type": "tarball",
@@ -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": {
@@ -99,11 +99,11 @@
}, },
"nixos-facter-modules": { "nixos-facter-modules": {
"locked": { "locked": {
"lastModified": 1756491981, "lastModified": 1756291602,
"narHash": "sha256-lXyDAWPw/UngVtQfgQ8/nrubs2r+waGEYIba5UX62+k=", "narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixos-facter-modules", "repo": "nixos-facter-modules",
"rev": "c1b29520945d3e148cd96618c8a0d1f850965d8c", "rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -181,11 +181,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1756662192, "lastModified": 1755934250,
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

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

View File

@@ -245,8 +245,6 @@ in
in in
{ config, ... }: { config, ... }:
{ {
staticModules = clan-core.clan.modules;
distributedServices = clanLib.inventory.mapInstances { distributedServices = clanLib.inventory.mapInstances {
inherit (clanConfig) inventory exportsModule; inherit (clanConfig) inventory exportsModule;
inherit flakeInputs directory; inherit flakeInputs directory;

View File

@@ -23,12 +23,6 @@ let
}; };
in in
{ {
options.staticModules = lib.mkOption {
readOnly = true;
type = lib.types.raw;
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
};
options.modulesPerSource = lib.mkOption { options.modulesPerSource = lib.mkOption {
# { sourceName :: { moduleName :: {} }} # { sourceName :: { moduleName :: {} }}
readOnly = true; readOnly = true;

View File

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

View File

@@ -10,7 +10,7 @@
lib.mkIf config.clan.core.enableRecommendedDefaults { lib.mkIf config.clan.core.enableRecommendedDefaults {
# Enable automatic state-version generation. # Enable automatic state-version generation.
clan.core.settings.state-version.enable = lib.mkDefault true; clan.core.settings.state-version.enable = true;
# Use systemd during boot as well except: # Use systemd during boot as well except:
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210 # - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210

View File

@@ -12,14 +12,8 @@ let
(builtins.match "linux_[0-9]+_[0-9]+" name) != null (builtins.match "linux_[0-9]+_[0-9]+" name) != null
&& (builtins.tryEval kernelPackages).success && (builtins.tryEval kernelPackages).success
&& ( && (
let (!isUnstable && !kernelPackages.zfs.meta.broken)
zfsPackage = || (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
if isUnstable then
kernelPackages.zfs_unstable
else
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
in
!(zfsPackage.meta.broken or false)
) )
) pkgs.linuxKernel.packages; ) pkgs.linuxKernel.packages;
latestKernelPackage = lib.last ( latestKernelPackage = lib.last (
@@ -30,5 +24,5 @@ let
in in
{ {
# Note this might jump back and worth as kernel get added or removed. # Note this might jump back and worth as kernel get added or removed.
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage; boot.kernelPackages = latestKernelPackage;
} }

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,10 +48,6 @@ 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 {
url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf";
hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w=";
};
in in
runCommand "" { } '' runCommand "" { } ''
@@ -66,5 +62,4 @@ 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
'' ''

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."
fi
# install desktop file on Linux
set -eou pipefail set -eou pipefail
DESKTOP_FILE_NAME=org.clan.app.desktop DESKTOP_FILE_NAME=org.clan.app.desktop
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
elif [[ "$OSTYPE" == "darwin"* ]]; then xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
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

@@ -23,7 +23,6 @@
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",
"troika-three-text": "^0.52.4",
"valibot": "^1.1.0" "valibot": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -3808,15 +3807,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7538,15 +7528,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/requires-port": { "node_modules/requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -8674,36 +8655,6 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/troika-three-text": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
"troika-three-utils": "^0.52.4",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-three-utils": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -9317,12 +9268,6 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/webgl-sdf-generator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@@ -80,7 +80,6 @@
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",
"troika-three-text": "^0.52.4",
"valibot": "^1.1.0" "valibot": "^1.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -1,33 +0,0 @@
.list {
display: flex;
width: 113px;
padding: 8px;
flex-direction: column;
align-items: flex-start;
border-radius: 5px;
border: 1px solid var(--clr-border-def-2, #d8e8eb);
background: var(--clr-bg-def-1, #fff);
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.24);
}
.item {
max-height: 28px;
height: 28px;
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
align-self: stretch;
gap: 4px;
&:hover {
@apply bg-def-3;
border-radius: 2px;
}
&[aria-disabled="true"] {
cursor: not-allowed;
pointer-events: none;
}
}

View File

@@ -1,66 +0,0 @@
import { onCleanup, onMount } from "solid-js";
import styles from "./ContextMenu.module.css";
import { Typography } from "../Typography/Typography";
export const Menu = (props: {
x: number;
y: number;
onSelect: (option: "move") => void;
close: () => void;
intersect: string[];
}) => {
let ref: HTMLUListElement;
const handleClickOutside = (e: MouseEvent) => {
if (!ref.contains(e.target as Node)) {
props.close();
}
};
onMount(() => {
document.addEventListener("mousedown", handleClickOutside);
});
onCleanup(() =>
document.removeEventListener("mousedown", handleClickOutside),
);
const currentMachine = () => props.intersect.at(0) || null;
return (
<ul
ref={(el) => (ref = el)}
style={{
position: "absolute",
top: `${props.y}px`,
left: `${props.x}px`,
"z-index": 1000,
"pointer-events": "auto",
}}
class={styles.list}
onContextMenu={(e) => {
// Prevent default context menu
e.preventDefault();
e.stopPropagation();
}}
>
<li
class={styles.item}
aria-disabled={!currentMachine()}
onClick={() => {
console.log("Move clicked", currentMachine());
props.onSelect("move");
props.close();
}}
>
<Typography
hierarchy="label"
size="s"
weight="bold"
color={currentMachine() ? "primary" : "quaternary"}
>
Move
</Typography>
</li>
</ul>
);
};

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

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

View File

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

View File

@@ -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,35 +1,29 @@
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";
import { Tag } from "@/src/components/Tag/Tag"; import { Tag } from "@/src/components/Tag/Tag";
import "./MachineTags.css";
import { Label } from "@/src/components/Form/Label"; import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter"; import { Orienter } from "@/src/components/Form/Orienter";
import { CollectionNode } from "@kobalte/core"; import { CollectionNode } from "@kobalte/core";
import styles from "./MachineTags.module.css";
export interface MachineTag { export interface MachineTag {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
new?: boolean;
} }
export type MachineTagsProps = FieldProps & { export type MachineTagsProps = FieldProps & {
name: string; name: string;
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,12 +44,26 @@ 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 = (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item item={props.item} class="item">
<Combobox.ItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
// // convert default value string[] into MachineTag[] export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions( const defaultValue = sortedAndUniqueOptions(
(local.defaultValue || []).map((value) => ({ value })), (props.defaultValue || []).map((value) => ({ value })),
); );
// convert default options string[] into MachineTag[] // convert default options string[] into MachineTag[]
@@ -69,51 +77,6 @@ export const MachineTags = (props: MachineTagsProps) => {
]), ]),
); );
const [selectedOptions, setSelectedOptions] =
createSignal<MachineTag[]>(defaultValue);
const handleToggle = (item: CollectionNode<MachineTag>) => () => {
setSelectedOptions((current) => {
const exists = current.find(
(option) => option.value === item.rawValue.value,
);
if (exists) {
return current.filter((option) => option.value !== item.rawValue.value);
}
return [...current, item.rawValue];
});
};
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
onClick={handleToggle(props.item)}
>
<Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
inverted={inverted}
>
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class={styles.itemIndicator}>
<Icon icon="Checkmark" inverted={inverted} />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
let selectRef: HTMLSelectElement;
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input // react when enter is pressed inside of the text input
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -122,49 +85,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";
@@ -176,19 +112,41 @@ export const MachineTags = (props: MachineTagsProps) => {
return ( return (
<Combobox<MachineTag> <Combobox<MachineTag>
multiple multiple
class={cx("form-field", styles.machineTags, props.orientation)} class={cx("form-field", "machine-tags", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...splitProps(props, ["defaultValue"])[1]} {...splitProps(props, ["defaultValue"])[1]}
defaultValue={defaultValue} defaultValue={defaultValue}
value={selectedOptions()}
options={availableOptions()} options={availableOptions()}
optionValue="value" optionValue="value"
optionTextValue="value" optionTextValue="value"
optionLabel="value" optionLabel="value"
optionDisabled="disabled" optionDisabled="disabled"
itemComponent={ItemComponent(props.inverted || false)} itemComponent={ItemComponent}
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,18 +156,11 @@ 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="control">
class={cx(styles.control, props.orientation)}
>
{(state) => ( {(state) => (
<div class={styles.selectedOptions}> <div class="selected-options">
<For each={state.selectedOptions()}> <For each={state.selectedOptions()}>
{(option) => ( {(option) => (
<Tag <Tag
@@ -226,13 +177,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,
);
})
}
/> />
) )
} }
@@ -242,36 +187,27 @@ export const MachineTags = (props: MachineTagsProps) => {
)} )}
</For> </For>
<Show when={!props.readOnly}> <Show when={!props.readOnly}>
<Combobox.Trigger class={styles.trigger}> <div class="input-container">
<Combobox.Input onKeyDown={onKeyDown} />
<Combobox.Trigger class="trigger">
<Combobox.Icon class="icon">
<Icon <Icon
icon="Tag" icon="Expand"
color="secondary" inverted={!props.inverted}
inverted={props.inverted} size="100%"
class={cx(styles.icon, {
[styles.iconSmall]: props.size == "s",
})}
/>
<Combobox.Input
onKeyDown={onKeyDown}
class={cx(styles.input, {
[styles.inputSmall]: props.size == "s",
[styles.inputGhost]: props.ghost,
[styles.inputInverted]: props.inverted,
})}
/> />
</Combobox.Icon>
</Combobox.Trigger> </Combobox.Trigger>
</div>
</Show> </Show>
</div> </div>
)} )}
</Combobox.Control> </Combobox.Control>
</Orienter> </Orienter>
<Combobox.Portal> <Combobox.Portal>
<Combobox.Content <Combobox.Content class="machine-tags-content">
class={cx(styles.comboboxContent, { <Combobox.Listbox class="listbox" />
[styles.comboboxContentInverted]: props.inverted,
})}
>
<Combobox.Listbox class={styles.listbox} />
</Combobox.Content> </Combobox.Content>
</Combobox.Portal> </Combobox.Portal>
</Combobox> </Combobox>

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

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

@@ -3,12 +3,12 @@ import Icon from "@/src/components/Icon/Icon";
import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import { createSignal, For, Show, Suspense } from "solid-js"; import { createSignal, For, Show, Suspense, useContext } from "solid-js";
import { navigateToOnboarding } from "@/src/hooks/clan"; import { navigateToOnboarding } from "@/src/hooks/clan";
import { setActiveClanURI } from "@/src/stores/clan"; import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan";
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal"; import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
import { useClanContext } from "@/src/routes/Clan/Clan";
export const SidebarHeader = () => { export const SidebarHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,7 +17,11 @@ export const SidebarHeader = () => {
const [showSettings, setShowSettings] = createSignal(false); const [showSettings, setShowSettings] = createSignal(false);
// get information about the current active clan // get information about the current active clan
const ctx = useClanContext(); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("SidebarContext not found");
}
const clanChar = () => const clanChar = () =>
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase(); ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();

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

@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
class?: string; class?: string;
title: string; title: string;
onClose: () => void; onClose: () => void;
subHeader?: JSX.Element; subHeader?: () => JSX.Element;
children: JSX.Element; children: JSX.Element;
} }
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
</KButton> </KButton>
</div> </div>
<Show when={props.subHeader}> <Show when={props.subHeader}>
<div class="sub-header">{props.subHeader}</div> <div class="sub-header">{props.subHeader!()}</div>
</Show> </Show>
<div class="body">{props.children}</div> <div class="body">{props.children}</div>
</div> </div>

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

@@ -1,11 +1,10 @@
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine"; import { InstallModal } from "@/src/workflows/Install/install";
import { useMachineName } from "@/src/hooks/clan"; 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,7 @@ export const useTags = (clanURI: string) => {
export const useMachineStateQuery = (clanURI: string, machineName: string) => { export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineState>(() => ({ return useQuery<MachineState>(() => ({
queryKey: [...machineKey(clanURI, machineName), "state"], queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("get_machine_state", { const apiCall = client.fetch("get_machine_state", {
machine: { machine: {
@@ -174,61 +165,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 +201,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 +252,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 +321,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 +485,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 +505,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,
@@ -11,83 +11,87 @@ import {
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { import {
buildClanPath,
buildMachinePath, buildMachinePath,
maybeUseMachineName, maybeUseMachineName,
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 { callApi } from "@/src/hooks/api";
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";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash"; import { Splash } from "@/src/scene/splash";
import cx from "classnames"; import cx from "classnames";
import styles from "./Clan.module.css"; import styles from "./Clan.module.css";
import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid";
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"; interface ClanContextProps {
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout"; clanURI: string;
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetails>;
otherClanQueries: UseQueryResult<ClanDetails>[];
allClansQueries: UseQueryResult<ClanDetails>[];
export type WorldMode = "default" | "select" | "service" | "create" | "move"; isLoading(): boolean;
isError(): boolean;
}
function createClanContext( class DefaultClanContext implements ClanContextProps {
public readonly clanURI: string;
public readonly activeClanQuery: UseQueryResult<ClanDetails>;
public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
public readonly allClansQueries: UseQueryResult<ClanDetails>[];
public readonly machinesQuery: MachinesQueryResult;
allQueries: UseQueryResult[];
constructor(
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"); this.clanURI = clanURI;
const [showAddMachine, setShowAddMachine] = createSignal(false); this.machinesQuery = machinesQuery;
const navigate = useNavigate(); this.activeClanQuery = activeClanQuery;
const location = useLocation(); this.otherClanQueries = otherClanQueries;
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
const allClansQueries = [activeClanQuery, ...otherClanQueries]; this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
return {
clanURI,
machinesQuery,
activeClanQuery,
otherClanQueries,
allClansQueries,
serviceInstancesQuery,
isLoading: () => allQueries.some((q) => q.isLoading),
isError: () => activeClanQuery.isError,
showAddMachine,
setShowAddMachine,
navigateToRoot: () => {
if (location.pathname === buildClanPath(clanURI)) return;
navigate(buildClanPath(clanURI), { replace: true });
},
setWorldMode,
worldMode,
};
} }
const ClanContext = createContext< isLoading(): boolean {
ReturnType<typeof createClanContext> | undefined return this.allQueries.some((q) => q.isLoading);
>();
export const useClanContext = () => {
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
} }
return ctx;
}; isError(): boolean {
return this.activeClanQuery.isError;
}
}
export const ClanContext = createContext<ClanContextProps>();
export const Clan: Component<RouteSectionProps> = (props) => { export const Clan: Component<RouteSectionProps> = (props) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
@@ -105,18 +109,18 @@ export const Clan: Component<RouteSectionProps> = (props) => {
); );
const machinesQuery = useMachinesQuery(clanURI); const machinesQuery = useMachinesQuery(clanURI);
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
const ctx = createClanContext( return (
<ClanContext.Provider
value={
new DefaultClanContext(
clanURI, clanURI,
machinesQuery, machinesQuery,
activeClanQuery, activeClanQuery,
otherClanQueries, otherClanQueries,
serviceInstancesQuery, )
); }
>
return (
<ClanContext.Provider value={ctx}>
<div <div
class={cx(styles.sidebarContainer, { class={cx(styles.sidebarContainer, {
[styles.machineSelected]: useMachineName(), [styles.machineSelected]: useMachineName(),
@@ -130,11 +134,67 @@ export const Clan: Component<RouteSectionProps> = (props) => {
); );
}; };
interface CreateFormValues extends FieldValues {
name: string;
}
interface MockProps {
onClose: () => void;
onSubmit: (formValues: CreateFormValues) => void;
}
const MockCreateMachine = (props: MockProps) => {
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return (
<Modal
open={true}
onClose={() => {
reset(form);
props.onClose();
}}
class={cx(styles.createModal)}
title="Create Machine"
>
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<TextInput
{...field}
label="Name"
size="s"
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
/>
</>
)}
</Field>
<div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Create
</Button>
</div>
</Form>
</Modal>
);
};
const ClanSceneController = (props: RouteSectionProps) => { const ClanSceneController = (props: RouteSectionProps) => {
const ctx = useClanContext(); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const navigate = useNavigate(); const navigate = useNavigate();
const [showService, setShowService] = createSignal(false);
const [showModal, setShowModal] = 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;
@@ -142,11 +202,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onCreate = async (): Promise<{ id: string }> => { const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ctx.setShowAddMachine(true); setShowModal(true);
setCurrentPromise({ resolve, reject }); setCurrentPromise({ resolve, reject });
}); });
}; };
const onAddService = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowService((v) => !v);
console.log("setting current promise");
setCurrentPromise({ resolve, reject });
});
};
const sendCreate = async (values: CreateFormValues) => {
const api = callApi("create_machine", {
opts: {
clan_dir: {
identifier: ctx.clanURI,
},
machine: {
name: values.name,
},
},
});
const res = await api.result;
if (res.status === "error") {
// TODO: Handle displaying errors
console.error("Error creating machine:");
// Important: rejects the promise
throw new Error(res.errors[0].message);
}
// trigger a refetch of the machines query
ctx.machinesQuery.refetch();
return { id: values.name };
};
const [loadingError, setLoadingError] = createSignal< const [loadingError, setLoadingError] = createSignal<
{ title: string; description: string } | undefined { title: string; description: string } | undefined
>(); >();
@@ -174,8 +268,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
const selected = ids.values().next().value; const selected = ids.values().next().value;
if (selected) { if (selected) {
navigate(buildMachinePath(ctx.clanURI, selected)); navigate(buildMachinePath(ctx.clanURI, selected));
} else {
navigate(buildClanPath(ctx.clanURI));
} }
}; };
@@ -195,25 +287,67 @@ 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");
//
currentPromise()?.resolve({ id: "0" });
setShowService(false);
};
createEffect(
on(worldMode, (mode) => {
if (mode === "service") {
setShowService(true);
} else {
// todo: request close instead of force close
setShowService(false);
}
}),
);
return ( return (
<> <>
<Show when={loadingError()}> <Show when={loadingError()}>
<ListClansModal error={loadingError()} /> <ListClansModal error={loadingError()} />
</Show> </Show>
<Show when={ctx.showAddMachine()}> <Show when={showModal()}>
<AddMachine <MockCreateMachine
onCreated={async (id) => {
const promise = currentPromise();
if (promise) {
await ctx.machinesQuery.refetch();
promise.resolve({ id });
setCurrentPromise(null);
}
}}
onClose={() => { onClose={() => {
ctx.setShowAddMachine(false); setShowModal(false);
currentPromise()?.reject(new Error("User cancelled"));
}}
onSubmit={async (values) => {
try {
const result = await sendCreate(values);
currentPromise()?.resolve(result);
setShowModal(false);
} catch (err) {
currentPromise()?.reject(err);
setShowModal(false);
}
}} }}
/> />
</Show> </Show>
@@ -231,19 +365,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("default");
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,12 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const Sections = () => { const sidebarPane = (machineName: string) => {
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,
@@ -56,43 +51,25 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery }; const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return ( return (
<> <div class={styles.sidebarPaneContainer}>
<SidebarSectionInstall <SidebarPane
clanURI={clanURI} title={machineName}
machineName={useMachineName()} onClose={onClose}
/> subHeader={() => (
<SidebarSectionUpdate <SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
clanURI={clanURI} )}
machineName={useMachineName()} >
/> <SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} /> <SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} /> <SectionTags {...sectionProps} />
<SectionServices /> </SidebarPane>
</> </div>
); );
}; };
return ( return (
<Show when={useMachineName()}> <Show when={useMachineName()} keyed>
<div class={styles.sidebarPaneContainer}> {sidebarPane(useMachineName())}
<SidebarPane
title={useMachineName()}
onClose={onClose}
// the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and
// present the user with the ability to install or update a machines based on `installedAt` in the inventory.json
//
// subHeader={
// <Show when={useMachineName()} keyed>
// <SidebarMachineStatus
// clanURI={clanURI}
// machineName={useMachineName()}
// />
// </Show>
// }
>
{Sections()}
</SidebarPane>
</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

@@ -27,7 +27,6 @@ export class MachineManager {
machinesQueryResult: MachinesQueryResult, machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>, selectedIds: Accessor<Set<string>>,
setMachinePos: (id: string, position: [number, number] | null) => void, setMachinePos: (id: string, position: [number, number] | null) => void,
camera: THREE.Camera,
) { ) {
this.machinePositionsSignal = machinePositionsSignal; this.machinePositionsSignal = machinePositionsSignal;
@@ -40,10 +39,10 @@ export class MachineManager {
const actualIds = Object.keys(machinesQueryResult.data); const actualIds = Object.keys(machinesQueryResult.data);
const machinePositions = machinePositionsSignal(); const machinePositions = machinePositionsSignal();
// Remove stale // Remove stale
for (const id of Object.keys(machinePositions)) { for (const id of Object.keys(machinePositions)) {
if (!actualIds.includes(id)) { if (!actualIds.includes(id)) {
console.log("Removing stale machine", id);
setMachinePos(id, null); setMachinePos(id, null);
} }
} }
@@ -62,11 +61,10 @@ export class MachineManager {
// //
createEffect(() => { createEffect(() => {
const positions = machinePositionsSignal(); const positions = machinePositionsSignal();
if (!positions) return;
// Remove machines from scene // Remove machines from scene
for (const [id, repr] of this.machines) { for (const [id, repr] of this.machines) {
if (!Object.keys(positions).includes(id)) { if (!(id in positions)) {
repr.dispose(scene); repr.dispose(scene);
this.machines.delete(id); this.machines.delete(id);
} }
@@ -83,7 +81,6 @@ export class MachineManager {
id, id,
selectedIds, selectedIds,
highlightGroups, highlightGroups,
camera,
); );
this.machines.set(id, repr); this.machines.set(id, repr);
scene.add(repr.group); scene.add(repr.group);

View File

@@ -1,10 +1,8 @@
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";
import { FontLoader } from "three/examples/jsm/Addons";
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
// Constants // Constants
const BASE_SIZE = 0.9; const BASE_SIZE = 0.9;
@@ -22,71 +20,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;
@@ -95,7 +28,6 @@ export class MachineRepr {
private baseMesh: THREE.Mesh; private baseMesh: THREE.Mesh;
private geometry: THREE.BoxGeometry; private geometry: THREE.BoxGeometry;
private material: THREE.MeshPhongMaterial; private material: THREE.MeshPhongMaterial;
private camera: THREE.Camera;
private disposeRoot: () => void; private disposeRoot: () => void;
@@ -106,25 +38,34 @@ export class MachineRepr {
id: string, id: string,
selectedSignal: Accessor<Set<string>>, selectedSignal: Accessor<Set<string>>,
highlightGroups: Record<string, Set<string>>, // Reactive store highlightGroups: Record<string, Set<string>>, // Reactive store
camera: THREE.Camera,
) { ) {
this.id = id; this.id = id;
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);
this.cubeMesh.add(label);
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,
@@ -141,7 +82,6 @@ export class MachineRepr {
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0); shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
this.group = new THREE.Group(); this.group = new THREE.Group();
this.group.add(label);
this.group.add(this.cubeMesh); this.group.add(this.cubeMesh);
this.group.add(this.baseMesh); this.group.add(this.baseMesh);
this.group.add(shadowPlane); this.group.add(shadowPlane);
@@ -158,6 +98,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 +116,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 +143,30 @@ 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 div = document.createElement("div");
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1; div.className = "machine-label";
const bgWidth = textWidth + 10 * padding; div.textContent = id;
// const bgWidth = text.text.length * 0.07 + padding; const label = new CSS2DObject(div);
const bgHeight = 0.1 + 2 * padding; label.position.set(0, CUBE_SIZE + 0.1, 0);
const radius = 0.02; return label;
const bgShape = roundedRectShape(bgWidth, bgHeight, radius);
const bgGeom = new THREE.ShapeGeometry(bgShape);
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 });
const bg = new THREE.Mesh(bgGeom, bgMat);
bg.position.set(0, 0, -0.01);
// --- Arrow (triangle pointing down) ---
const arrowShape = new THREE.Shape();
arrowShape.moveTo(-0.05, 0);
arrowShape.lineTo(0.05, 0);
arrowShape.lineTo(0, -0.05);
arrowShape.closePath();
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
const arrow = new THREE.Mesh(arrowGeom, bgMat);
arrow.position.set(0, -bgHeight / 2, -0.001);
// --- Group ---
group.add(bg);
group.add(arrow);
group.add(text);
// Position above cube
group.position.set(0, CUBE_SIZE + 0.3, 0);
// Billboard
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
group.quaternion.copy(this.camera.quaternion);
return group;
} }
dispose(scene: THREE.Scene) { dispose(scene: THREE.Scene) {
@@ -286,13 +176,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

@@ -1,7 +1,7 @@
import { Scene, Camera, WebGLRenderer } from "three"; import { Scene, Camera, WebGLRenderer } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js"; import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js"; import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import * as THREE from "three";
/** /**
* Private class to manage the render loop * Private class to manage the render loop
* @internal * @internal
@@ -93,18 +93,6 @@ class RenderLoop {
this.renderer.render(this.bgScene, this.bgCamera); this.renderer.render(this.bgScene, this.bgCamera);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
this.scene.traverse((obj) => {
if (obj.userData.isLabel) {
(obj as THREE.Mesh).quaternion.copy(this.camera.quaternion);
}
// if (obj.userData.isLabel) {
// const camPos = new THREE.Vector3();
// this.camera.getWorldPosition(camPos);
// obj.lookAt(new THREE.Vector3(camPos.x, obj.position.y, camPos.z));
// }
});
this.labelRenderer.render(this.scene, this.camera); this.labelRenderer.render(this.scene, this.camera);
} }

View File

@@ -1,12 +1,13 @@
.cubes-scene-container { .cubes-scene-container {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
cursor: pointer;
} }
/* <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

@@ -5,7 +5,6 @@ import {
onMount, onMount,
on, on,
JSX, JSX,
Show,
} from "solid-js"; } from "solid-js";
import "./cubes.css"; import "./cubes.css";
@@ -22,39 +21,6 @@ import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop"; import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry"; import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager"; import { MachineManager } from "./MachineManager";
import cx from "classnames";
import { Portal } from "solid-js/web";
import { Menu } from "../components/ContextMenu/ContextMenu";
import {
clearHighlight,
highlightGroups,
setHighlightGroups,
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
function intersectMachines(
event: MouseEvent,
renderer: THREE.WebGLRenderer,
camera: THREE.Camera,
machineManager: MachineManager,
raycaster: THREE.Raycaster,
) {
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
return {
machines: intersects.map((i) => i.object.userData.id),
intersection: intersects,
};
}
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) { for (const child of group.children) {
@@ -95,6 +61,12 @@ export function useMachineClick() {
return lastClickedMachine; return lastClickedMachine;
} }
/*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create"
>("default");
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 +78,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;
@@ -117,8 +87,7 @@ export function CubeScene(props: {
let controls: MapControls; let controls: MapControls;
// Raycaster for clicking // Raycaster for clicking
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
let actionBase: THREE.Mesh | undefined; let initBase: 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,27 +98,16 @@ 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
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({
position: { x: 0, y: 0, z: 0 }, position: { x: 0, y: 0, z: 0 },
spherical: { radius: 0, theta: 0, phi: 0 }, spherical: { radius: 0, theta: 0, phi: 0 },
}); });
// Context menu state
const [contextOpen, setContextOpen] = createSignal(false);
const [menuPos, setMenuPos] = createSignal<{ x: number; y: number }>();
const [menuIntersection, setMenuIntersection] = createSignal<string[]>([]);
// Grid configuration // Grid configuration
const GRID_SIZE = 1; const GRID_SIZE = 1;
@@ -165,10 +123,8 @@ export function CubeScene(props: {
const BASE_COLOR = 0xecfdff; const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c; const BASE_EMISSIVE = 0x0c0c0c;
const ACTION_BASE_COLOR = 0x636363; const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7; const CREATE_BASE_EMISSIVE = 0xc5fad7;
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
function createCubeBase( function createCubeBase(
cube_pos: [number, number, number], cube_pos: [number, number, number],
@@ -189,6 +145,12 @@ export function CubeScene(props: {
return base; return base;
} }
function toggleSelection(id: string) {
const next = new Set<string>();
next.add(id);
props.onSelect(next);
}
const initialCameraPosition = { x: 20, y: 20, z: 20 }; const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical(); const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3( initialSphericalCameraPosition.setFromVector3(
@@ -311,13 +273,6 @@ export function CubeScene(props: {
bgCamera, bgCamera,
); );
// controls.addEventListener("start", (e) => {
// setIsDragging(true);
// });
// controls.addEventListener("end", (e) => {
// setIsDragging(false);
// });
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72); const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
scene.add(ambientLight); scene.add(ambientLight);
@@ -385,32 +340,15 @@ export function CubeScene(props: {
); );
// Important create CubeBase depends on sharedBaseGeometry // Important create CubeBase depends on sharedBaseGeometry
actionBase = createCubeBase( initBase = createCubeBase(
[1, BASE_HEIGHT / 2, 1], [1, BASE_HEIGHT / 2, 1],
1, 1,
ACTION_BASE_COLOR, CREATE_BASE_COLOR,
CREATE_BASE_EMISSIVE, CREATE_BASE_EMISSIVE,
); );
actionBase.visible = false; initBase.visible = false;
scene.add(actionBase); scene.add(initBase);
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,11 +375,11 @@ export function CubeScene(props: {
updateCameraInfo(); updateCameraInfo();
createEffect( createEffect(
on(ctx.worldMode, (mode) => { on(worldMode, (mode) => {
if (mode === "create") { if (mode === "create") {
actionBase!.visible = true; initBase!.visible = true;
} else { } else {
actionBase!.visible = false; initBase!.visible = false;
} }
renderLoop.requestRender(); renderLoop.requestRender();
}), }),
@@ -449,21 +387,20 @@ 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,
props.cubesQuery, props.cubesQuery,
props.selectedIds, props.selectedIds,
props.setMachinePos, props.setMachinePos,
camera,
); );
// Click handler: // Click handler:
// - 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 }) => {
@@ -479,20 +416,11 @@ export function CubeScene(props: {
console.error("Error creating cube:", error); console.error("Error creating cube:", error);
}) })
.finally(() => { .finally(() => {
if (actionBase) actionBase.visible = false; if (initBase) initBase.visible = false;
ctx.setWorldMode("select"); setWorldMode("default");
}); });
} }
if (ctx.worldMode() === "move") {
const currId = menuIntersection().at(0);
const pos = cursorPosition();
if (!currId || !pos) return;
props.setMachinePos(currId, pos);
ctx.setWorldMode("select");
clearHighlight("move");
}
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2( const mouse = new THREE.Vector2(
@@ -504,20 +432,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") toggleSelection(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>()); props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
} }
}; };
@@ -548,77 +474,18 @@ export function CubeScene(props: {
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const handleMouseDown = (e: MouseEvent) => {
const { machines, intersection } = intersectMachines(
e,
renderer,
camera,
machineManager,
raycaster,
);
if (e.button === 0) {
// Left button
if (ctx.worldMode() === "select" && machines.length) {
// Disable controls to avoid conflict
controls.enabled = false;
// Change cursor to grabbing
// LongPress, if not canceled, enters move mode
const cancelMove = setTimeout(() => {
setIsDragging(true);
const pos =
machineManager.machines.get(machines[0])?.group.position ||
new THREE.Vector3(0, 0, 0);
actionMachine?.position.set(pos.x, 0, pos.z);
// Set machine as flying
setHighlightGroups({ move: new Set(machines) });
ctx.setWorldMode("move");
renderLoop.requestRender();
}, 500);
setCancelMove(cancelMove);
}
}
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
if (!intersection.length) return;
setMenuIntersection(machines);
setMenuPos({ x: e.clientX, y: e.clientY });
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("mouseup", handleMouseUp);
renderer.domElement.addEventListener("mousemove", onMouseMove); renderer.domElement.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// For debugging,
// TODO: Remove in production
window.addEventListener(
"contextmenu",
(e) => {
e.stopPropagation();
},
{ capture: true },
);
// Initial render // Initial render
renderLoop.requestRender(); renderLoop.requestRender();
@@ -645,12 +512,12 @@ export function CubeScene(props: {
renderer.domElement.removeEventListener("mousemove", onMouseMove); renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
if (actionBase) { if (initBase) {
actionBase.geometry.dispose(); initBase.geometry.dispose();
if (Array.isArray(actionBase.material)) { if (Array.isArray(initBase.material)) {
actionBase.material.forEach((material) => material.dispose()); initBase.material.forEach((material) => material.dispose());
} else { } else {
actionBase.material.dispose(); initBase.material.dispose();
} }
} }
@@ -660,58 +527,16 @@ 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") return;
if (!initBase) return;
const actionRepr = initBase.visible = true;
ctx.worldMode() === "create" ? actionBase : actionMachine;
if (!actionRepr) return;
actionRepr.visible = true;
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
// );
// Calculate mouse position in normalized device coordinates
// (-1 to +1) for both components
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2( const mouse = new THREE.Vector2(
@@ -723,71 +548,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(initBase.position.x - snapped.x) > 0.01 ||
Math.abs(actionRepr.position.z - snapped.z) > 0.01 Math.abs(initBase.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); initBase.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") => {
ctx.setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) });
// 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 (
<> <>
<Show when={contextOpen()}> <div class="cubes-scene-container" ref={(el) => (container = el)} />
<Portal mount={document.body}>
<Menu
onSelect={handleMenuSelect}
intersect={menuIntersection()}
x={menuPos()!.x - 10}
y={menuPos()!.y - 10}
close={() => setContextOpen(false)}
/>
</Portal>
</Show>
<div
class={cx(
"cubes-scene-container",
ctx.worldMode() === "default" && "cursor-no-drop",
ctx.worldMode() === "select" && "cursor-pointer",
ctx.worldMode() === "service" && "cursor-pointer",
ctx.worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing",
)}
ref={(el) => (container = el)}
/>
<div class="toolbar-container"> <div class="toolbar-container">
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"> <div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
{props.toolbarPopup} {props.toolbarPopup}
@@ -797,29 +592,30 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => ctx.setWorldMode("select")} onClick={() =>
selected={ctx.worldMode() === "select"} setWorldMode((v) => (v === "select" ? "default" : "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((v) => (v === "service" ? "default" : "service"));
ctx.setWorldMode("service");
}} }}
/> />
<ToolbarButton <ToolbarButton
icon="Update" icon="Reload"
name="Reload" name="Reload"
description="Reload machines" description="Reload machines"
onClick={() => machinesQuery.refetch()} onClick={() => machinesQuery.refetch()}

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

@@ -1,125 +0,0 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
list_machines: {
pandora: {
data: {
name: "pandora",
},
},
enceladus: {
data: {
name: "enceladus",
},
},
dione: {
data: {
name: "dione",
},
},
},
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof AddMachine> = {
title: "workflows/add-machine",
component: AddMachine,
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 AddMachine>;
export const General: Story = {
args: {},
};
export const Host: Story = {
args: {
initialStep: "host",
},
};
export const Tags: Story = {
args: {
initialStep: "tags",
},
};
export const Progress: Story = {
args: {
initialStep: "progress",
},
};

View File

@@ -1,133 +0,0 @@
import {
createStepper,
defineSteps,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import {
GeneralForm,
StepGeneral,
} from "@/src/workflows/AddMachine/StepGeneral";
import { Modal } from "@/src/components/Modal/Modal";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
import { Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
import { StepProgress } from "./StepProgress";
interface AddMachineStepperProps {
onDone: () => void;
}
const AddMachineStepper = (props: AddMachineStepperProps) => {
const stepSignal = useStepper<AddMachineSteps>();
return (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
/>
);
};
export interface AddMachineProps {
onClose: () => void;
onCreated: (id: string) => void;
initialStep?: AddMachineSteps[number]["id"];
}
export interface AddMachineStoreType {
general: GeneralForm;
deploy: {
targetHost?: string;
};
tags: {
tags: string[];
};
onCreated: (id: string) => void;
error?: string;
}
const steps = defineSteps([
{
id: "general",
title: "General",
content: StepGeneral,
},
{
id: "host",
title: "Host",
content: StepHost,
},
{
id: "tags",
title: "Tags",
content: StepTags,
},
{
id: "progress",
title: "Creating...",
content: StepProgress,
isSplash: true,
},
] as const);
export type AddMachineSteps = typeof steps;
export const AddMachine = (props: AddMachineProps) => {
const stepper = createStepper(
{
steps,
},
{
initialStep: props.initialStep || "general",
initialStoreData: { onCreated: props.onCreated },
},
);
const MetaHeader = () => {
const title = stepper.currentStep().title;
return (
<Show when={title}>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="medium"
>
{title}
</Typography>
</Show>
);
};
const sizeClasses = () => {
const defaultClass = "max-w-3xl h-fit";
const currentStep = stepper.currentStep();
if (!currentStep) {
return defaultClass;
}
return defaultClass;
};
return (
<StepperProvider stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Add Machine"
onClose={props.onClose}
open={true}
// @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}
>
<AddMachineStepper onDone={() => props.onClose()} />
</Modal>
</StepperProvider>
);
};

View File

@@ -1,176 +0,0 @@
import { NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
clearError,
createForm,
FieldValues,
getError,
getErrors,
setError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
import { Select } from "@/src/components/Select/Select";
import { Show } from "solid-js";
import { Alert } from "@/src/components/Alert/Alert";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
const PlatformOptions = [
{ label: "NixOS", value: "nixos" },
{ label: "Darwin", value: "darwin" },
];
const GeneralSchema = v.object({
name: v.pipe(
v.string("Name must be a string"),
v.nonEmpty("Please enter a machine name"),
v.regex(
new RegExp(/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/),
"Name must be a valid hostname e.g. alphanumeric characters and - only",
),
),
description: v.optional(v.string("Description must be a string")),
machineClass: v.pipe(v.string(), v.nonEmpty()),
});
export interface GeneralForm extends FieldValues {
machineClass: "nixos" | "darwin";
name: string;
description?: string;
}
export const StepGeneral = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const clanURI = useClanURI();
const machines = useMachinesQuery(clanURI);
const machineNames = () => {
if (!machines.isSuccess) {
return [];
}
return Object.keys(machines.data || {});
};
const [formStore, { Form, Field }] = createForm<GeneralForm>({
validate: valiForm(GeneralSchema),
initialValues: { ...store.general, machineClass: "nixos" },
});
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
if (machineNames().includes(values.name)) {
setError(
formStore,
"name",
`A machine named '${values.name}' already exists. Please choose a different one.`,
);
return;
}
clearError(formStore, "name");
set("general", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
const formError = () => {
const errors = getErrors(formStore);
return errors.name || errors.description || errors.machineClass;
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Show when={formError()}>
<Alert
type="error"
icon="WarningFilled"
title="Error"
description={formError()}
/>
</Show>
<Fieldset>
<Field name="name">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Name"
required
orientation="horizontal"
input={{
...input,
placeholder: "A unique machine name.",
}}
validationState={
getError(formStore, "name") ? "invalid" : "valid"
}
/>
)}
</Field>
<Divider />
<Field name="description">
{(field, input) => (
<TextArea
{...field}
value={field.value}
label="Description"
orientation="horizontal"
input={{
...input,
placeholder: "A short description of the machine.",
}}
validationState={
getError(formStore, "description") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
<Fieldset>
<Field name="machineClass">
{(field, props) => (
<Select
zIndex={100}
{...props}
value={field.value}
error={field.error}
required
label={{
label: "Platform",
}}
options={PlatformOptions}
name={field.name}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-end">
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

View File

@@ -1,76 +0,0 @@
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
const HostSchema = v.object({
targetHost: v.pipe(v.string("Name must be a string")),
});
type HostForm = v.InferInput<typeof HostSchema>;
export const StepHost = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const [formStore, { Form, Field }] = createForm<HostForm>({
validate: valiForm(HostSchema),
initialValues: store.deploy,
});
const handleSubmit: SubmitHandler<HostForm> = (values, event) => {
set("deploy", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="targetHost">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Target"
orientation="horizontal"
input={{
...input,
placeholder: "root@flashinstaller.local",
}}
validationState={
getError(formStore, "targetHost") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

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