Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584f3247a | ||
|
|
d3534a2b72 |
6
checks/clan-core-for-checks.nix
Normal file
6
checks/clan-core-for-checks.nix
Normal file
@@ -0,0 +1,6 @@
|
||||
{ fetchgit }:
|
||||
fetchgit {
|
||||
url = "https://git.clan.lol/clan/clan-core.git";
|
||||
rev = "5d884cecc2585a29b6a3596681839d081b4de192";
|
||||
sha256 = "09is1afmncamavb2q88qac37vmsijxzsy1iz1vr6gsyjq2rixaxc";
|
||||
}
|
||||
@@ -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.diskoScript
|
||||
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);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -18,23 +18,27 @@
|
||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||
|
||||
imports = [
|
||||
self.nixosModules.test-install-machine-without-system
|
||||
];
|
||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||
};
|
||||
|
||||
clan.machines.test-install-machine-with-system =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
# 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";
|
||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||
|
||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||
};
|
||||
|
||||
flake.nixosModules = {
|
||||
test-install-machine-without-system =
|
||||
{ lib, modulesPath, ... }:
|
||||
@@ -155,7 +159,6 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.bash.drvPath
|
||||
pkgs.buildPackages.xorg.lndir
|
||||
(import ./facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.stdenvNoCC
|
||||
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);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.bash.drvPath
|
||||
pkgs.buildPackages.xorg.lndir
|
||||
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
};
|
||||
|
||||
@@ -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 = { };
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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")
|
||||
'';
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{
|
||||
"publickey": "age1yd2cden7jav8x4nzx2fwze2fsa5j0qm2m3t7zum765z3u4gj433q7dqj43",
|
||||
"type": "age"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{
|
||||
"publickey": "age1js225d8jc507sgcg0fdfv2x3xv3asm4ds5c6s4hp37nq8spxu95sc5x3ce",
|
||||
"type": "age"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{
|
||||
"publickey": "age1nwuh8lc604mnz5r8ku8zswyswnwv02excw237c0cmtlejp7xfp8sdrcwfa",
|
||||
"type": "age"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../../users/admin
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../../users/admin
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../../users/admin
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||
"type": "age"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
25.11
|
||||
@@ -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-----
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../sops/machines/ca
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../sops/users/admin
|
||||
@@ -1 +0,0 @@
|
||||
25.11
|
||||
@@ -1 +0,0 @@
|
||||
25.11
|
||||
@@ -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-----
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../sops/users/admin
|
||||
@@ -1,5 +1,4 @@
|
||||
{ ... }:
|
||||
|
||||
{
|
||||
_class = "clan.service";
|
||||
manifest.name = "coredns";
|
||||
@@ -26,12 +25,6 @@
|
||||
# TODO: Set a default
|
||||
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 =
|
||||
@@ -49,8 +42,8 @@
|
||||
}:
|
||||
{
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ settings.dnsPort ];
|
||||
networking.firewall.allowedUDPPorts = [ settings.dnsPort ];
|
||||
networking.firewall.allowedTCPPorts = [ 53 ];
|
||||
networking.firewall.allowedUDPPorts = [ 53 ];
|
||||
|
||||
services.coredns =
|
||||
let
|
||||
@@ -81,22 +74,16 @@
|
||||
in
|
||||
{
|
||||
enable = true;
|
||||
config =
|
||||
config = ''
|
||||
. {
|
||||
forward . 1.1.1.1
|
||||
cache 30
|
||||
}
|
||||
|
||||
let
|
||||
dnsPort = builtins.toString settings.dnsPort;
|
||||
in
|
||||
|
||||
''
|
||||
.:${dnsPort} {
|
||||
forward . 1.1.1.1
|
||||
cache 30
|
||||
}
|
||||
|
||||
${settings.tld}:${dnsPort} {
|
||||
file ${zonefile}
|
||||
}
|
||||
'';
|
||||
${settings.tld} {
|
||||
file ${zonefile}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -120,16 +107,10 @@
|
||||
# TODO: Set a default
|
||||
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 =
|
||||
{ roles, settings, ... }:
|
||||
{ roles, ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{ lib, ... }:
|
||||
@@ -166,7 +147,7 @@
|
||||
];
|
||||
stub-zone = map (m: {
|
||||
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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,15 +95,18 @@
|
||||
for m in machines:
|
||||
m.wait_for_unit("network-online.target")
|
||||
|
||||
# import time
|
||||
# time.sleep(2333333)
|
||||
|
||||
# This should work, but is borken in tests i think? Instead we dig directly
|
||||
|
||||
# client.succeed("curl -k -v http://one.foo")
|
||||
# client.succeed("curl -k -v http://two.foo")
|
||||
|
||||
answer = client.succeed("dig @192.168.1.2 -p 1053 one.foo")
|
||||
answer = client.succeed("dig @192.168.1.2 one.foo")
|
||||
assert "192.168.1.3" in answer, "IP not found"
|
||||
|
||||
answer = client.succeed("dig @192.168.1.2 -p 1053 two.foo")
|
||||
answer = client.succeed("dig @192.168.1.2 two.foo")
|
||||
assert "192.168.1.4" in answer, "IP not found"
|
||||
|
||||
'';
|
||||
|
||||
@@ -56,11 +56,6 @@
|
||||
systemd.services.telegraf-json = {
|
||||
enable = true;
|
||||
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}";
|
||||
};
|
||||
|
||||
|
||||
@@ -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}"
|
||||
# '')
|
||||
# ];
|
||||
# };
|
||||
# };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||
"type": "age"
|
||||
}
|
||||
18
devFlake/flake.lock
generated
18
devFlake/flake.lock
generated
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1757195359,
|
||||
"narHash": "sha256-Uf/d5NGvq+Q6ct+n5xRr76N1ZGV0vkfsJ6iVTciPkY0=",
|
||||
"lastModified": 1756400612,
|
||||
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f4cefbe0160ba99567be386a043824549ccd5cb7",
|
||||
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +107,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756738487,
|
||||
"narHash": "sha256-8QX7Ab5CcICp7zktL47VQVS+QeaU4YDNAjzty7l7TQE=",
|
||||
"lastModified": 1755555503,
|
||||
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"rev": "5feeaeefb571e6ca2700888b944f436f7c05149b",
|
||||
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -165,11 +165,11 @@
|
||||
"nixpkgs": []
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756662192,
|
||||
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||
"lastModified": 1755934250,
|
||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -94,7 +94,6 @@ nav:
|
||||
- reference/clanServices/index.md
|
||||
- reference/clanServices/admin.md
|
||||
- reference/clanServices/borgbackup.md
|
||||
- reference/clanServices/certificates.md
|
||||
- reference/clanServices/coredns.md
|
||||
- reference/clanServices/data-mesher.md
|
||||
- reference/clanServices/dyndns.md
|
||||
|
||||
@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
|
||||
# Direct SSH with fallback support
|
||||
internet = {
|
||||
roles.default.machines.server1 = {
|
||||
settings.host = "server1.example.com";
|
||||
settings.address = "server1.example.com";
|
||||
};
|
||||
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
|
||||
internet = {
|
||||
roles.default.machines.publicserver = {
|
||||
settings.host = "public.example.com";
|
||||
settings.address = "public.example.com";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
38
flake.lock
generated
38
flake.lock
generated
@@ -13,11 +13,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756695982,
|
||||
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=",
|
||||
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb",
|
||||
"lastModified": 1756091210,
|
||||
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
|
||||
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
|
||||
"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": {
|
||||
"type": "tarball",
|
||||
@@ -31,11 +31,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756733629,
|
||||
"narHash": "sha256-dwWGlDhcO5SMIvMSTB4mjQ5Pvo2vtxvpIknhVnSz2I8=",
|
||||
"lastModified": 1756115622,
|
||||
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "a5c4f2ab72e3d1ab43e3e65aa421c6f2bd2e12a1",
|
||||
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -51,11 +51,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756770412,
|
||||
"narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=",
|
||||
"lastModified": 1754487366,
|
||||
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "4524271976b625a4a605beefd893f270620fd751",
|
||||
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -71,11 +71,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1757130842,
|
||||
"narHash": "sha256-4i7KKuXesSZGUv0cLPLfxbmF1S72Gf/3aSypgvVkwuA=",
|
||||
"lastModified": 1755825449,
|
||||
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "15f067638e2887c58c4b6ba1bdb65a0b61dc58c5",
|
||||
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -99,11 +99,11 @@
|
||||
},
|
||||
"nixos-facter-modules": {
|
||||
"locked": {
|
||||
"lastModified": 1756491981,
|
||||
"narHash": "sha256-lXyDAWPw/UngVtQfgQ8/nrubs2r+waGEYIba5UX62+k=",
|
||||
"lastModified": 1756291602,
|
||||
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-facter-modules",
|
||||
"rev": "c1b29520945d3e148cd96618c8a0d1f850965d8c",
|
||||
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -181,11 +181,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756662192,
|
||||
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||
"lastModified": 1755934250,
|
||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -87,8 +87,6 @@ in
|
||||
relativeDir = removePrefix "${self}/" (toString config.clan.directory);
|
||||
|
||||
update-vars = hostPkgs.writeShellScriptBin "update-vars" ''
|
||||
set -x
|
||||
export PRJ_ROOT=$(git rev-parse --show-toplevel)
|
||||
${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName}
|
||||
'';
|
||||
|
||||
|
||||
@@ -245,8 +245,6 @@ in
|
||||
in
|
||||
{ config, ... }:
|
||||
{
|
||||
staticModules = clan-core.clan.modules;
|
||||
|
||||
distributedServices = clanLib.inventory.mapInstances {
|
||||
inherit (clanConfig) inventory exportsModule;
|
||||
inherit flakeInputs directory;
|
||||
|
||||
@@ -23,12 +23,6 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
options.staticModules = lib.mkOption {
|
||||
readOnly = true;
|
||||
type = lib.types.raw;
|
||||
|
||||
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
|
||||
};
|
||||
options.modulesPerSource = lib.mkOption {
|
||||
# { sourceName :: { moduleName :: {} }}
|
||||
readOnly = true;
|
||||
|
||||
@@ -268,14 +268,8 @@ class Machine:
|
||||
)
|
||||
|
||||
def nsenter_command(self, command: str) -> list[str]:
|
||||
nsenter = shutil.which("nsenter")
|
||||
|
||||
if not nsenter:
|
||||
msg = "nsenter command not found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return [
|
||||
nsenter,
|
||||
"nsenter",
|
||||
"--target",
|
||||
str(self.container_pid),
|
||||
"--mount",
|
||||
@@ -332,7 +326,6 @@ class Machine:
|
||||
|
||||
return subprocess.run(
|
||||
self.nsenter_command(command),
|
||||
env={},
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||
|
||||
# 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:
|
||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
||||
|
||||
@@ -12,14 +12,8 @@ let
|
||||
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
||||
&& (builtins.tryEval kernelPackages).success
|
||||
&& (
|
||||
let
|
||||
zfsPackage =
|
||||
if isUnstable then
|
||||
kernelPackages.zfs_unstable
|
||||
else
|
||||
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
|
||||
in
|
||||
!(zfsPackage.meta.broken or false)
|
||||
(!isUnstable && !kernelPackages.zfs.meta.broken)
|
||||
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
|
||||
)
|
||||
) pkgs.linuxKernel.packages;
|
||||
latestKernelPackage = lib.last (
|
||||
@@ -30,5 +24,5 @@ let
|
||||
in
|
||||
{
|
||||
# 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;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
title="Clan App",
|
||||
size=Size(1280, 1024, SizeHint.NONE),
|
||||
shared_threads=shared_threads,
|
||||
app_id="org.clan.app",
|
||||
)
|
||||
|
||||
API.overwrite_fn(get_system_file)
|
||||
|
||||
@@ -5,11 +5,6 @@ import platform
|
||||
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
|
||||
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:
|
||||
return s.encode("utf-8")
|
||||
@@ -77,10 +72,6 @@ class _WebviewLibrary:
|
||||
self.webview_create.argtypes = [c_int, 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.argtypes = [c_void_p]
|
||||
|
||||
@@ -114,10 +105,6 @@ class _WebviewLibrary:
|
||||
self.webview_return = self.lib.webview_return
|
||||
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.CFUNCTYPE = CFUNCTYPE
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
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.tasks import WebThread
|
||||
|
||||
from ._webview_ffi import (
|
||||
_encode_c_string,
|
||||
_webview_lib,
|
||||
)
|
||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -36,21 +32,6 @@ class FuncStatus(IntEnum):
|
||||
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)
|
||||
class Size:
|
||||
width: int
|
||||
@@ -65,7 +46,6 @@ class Webview:
|
||||
size: Size | None = None
|
||||
window: int | None = None
|
||||
shared_threads: dict[str, WebThread] | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
# initialized later
|
||||
_bridge: WebviewBridge | None = None
|
||||
@@ -76,14 +56,7 @@ class Webview:
|
||||
def _create_handle(self) -> None:
|
||||
# Initialize the webview handle
|
||||
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]] = {}
|
||||
|
||||
# Since we can't use object.__setattr__, we'll initialize differently
|
||||
@@ -244,21 +217,6 @@ class Webview:
|
||||
self._callbacks[name] = c_callback
|
||||
_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:
|
||||
if name in self._callbacks:
|
||||
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))
|
||||
|
||||
@@ -11,11 +11,6 @@
|
||||
gobject-introspection,
|
||||
gtk4,
|
||||
lib,
|
||||
stdenv,
|
||||
# macOS-specific dependencies
|
||||
imagemagick,
|
||||
makeWrapper,
|
||||
libicns,
|
||||
}:
|
||||
let
|
||||
source =
|
||||
@@ -96,12 +91,7 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
# gtk4 deps
|
||||
wrapGAppsHook4
|
||||
]
|
||||
++ runtimeDependencies
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
imagemagick
|
||||
makeWrapper
|
||||
libicns
|
||||
];
|
||||
++ runtimeDependencies;
|
||||
|
||||
# The necessity of setting buildInputs and propagatedBuildInputs to the
|
||||
# same values for your Python package within Nix largely stems from ensuring
|
||||
@@ -158,113 +148,16 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
postInstall = ''
|
||||
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
|
||||
|
||||
${lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
|
||||
mkdir -p $out/share/icons/hicolor
|
||||
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
|
||||
''}
|
||||
|
||||
${lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
set -eu pipefail
|
||||
# Create macOS app bundle structure
|
||||
mkdir -p "$out/Applications/Clan App.app/Contents/"{MacOS,Resources}
|
||||
|
||||
# Create Info.plist
|
||||
cat > "$out/Applications/Clan App.app/Contents/Info.plist" << 'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Clan App</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Clan App</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>clan-app.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.clan.app</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Clan App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Clan Protocol</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>clan</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Create app icon (convert PNG to ICNS using minimal approach to avoid duplicates)
|
||||
# Create a temporary iconset directory structure
|
||||
mkdir clan-app.iconset
|
||||
|
||||
# Create a minimal iconset with only essential, non-duplicate sizes
|
||||
# Each PNG file should map to a unique ICNS type
|
||||
cp ./clan_app/assets/white-favicons/16x16/apps/clan-app.png clan-app.iconset/icon_16x16.png
|
||||
cp ./clan_app/assets/white-favicons/128x128/apps/clan-app.png clan-app.iconset/icon_128x128.png
|
||||
|
||||
# Use libicns png2icns tool to create proper ICNS file with minimal set
|
||||
png2icns "$out/Applications/Clan App.app/Contents/Resources/clan-app.icns" \
|
||||
clan-app.iconset/icon_16x16.png \
|
||||
clan-app.iconset/icon_128x128.png
|
||||
|
||||
# Create PkgInfo file (standard requirement for macOS apps)
|
||||
echo -n "APPL????" > "$out/Applications/Clan App.app/Contents/PkgInfo"
|
||||
|
||||
# Create the main executable script with proper process name
|
||||
cat > "$out/Applications/Clan App.app/Contents/MacOS/Clan App" << EOF
|
||||
#!/bin/bash
|
||||
# Execute with the correct process name for app icon to appear
|
||||
exec -a "\$0" "$out/bin/.clan-app-orig" "\$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$out/Applications/Clan App.app/Contents/MacOS/Clan App"
|
||||
set +eu pipefail
|
||||
''}
|
||||
mkdir -p $out/share/icons/hicolor
|
||||
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
|
||||
'';
|
||||
|
||||
# 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.
|
||||
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
|
||||
postFixup = ''
|
||||
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 = ''
|
||||
set -eu pipefail
|
||||
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
|
||||
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
|
||||
|
||||
@@ -278,7 +171,6 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
fc-list
|
||||
|
||||
PYTHONPATH= $out/bin/clan-app --help
|
||||
set +eu pipefail
|
||||
'';
|
||||
desktopItems = [ desktop-file ];
|
||||
}
|
||||
|
||||
@@ -48,10 +48,6 @@ let
|
||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
||||
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
|
||||
runCommand "" { } ''
|
||||
@@ -66,5 +62,4 @@ runCommand "" { } ''
|
||||
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
||||
|
||||
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
||||
cp ${archivoSemi_ttf} $out/ArchivoSemiCondensed-Medium.ttf
|
||||
''
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/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"))')
|
||||
|
||||
if [ "$ALREADY_INSTALLED" = "true" ]; then
|
||||
@@ -9,23 +14,9 @@ else
|
||||
nix profile install .#clan-app
|
||||
fi
|
||||
|
||||
# Check OS type
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
|
||||
if ! command -v xdg-mime &> /dev/null; then
|
||||
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
||||
fi
|
||||
# install desktop file
|
||||
set -eou pipefail
|
||||
DESKTOP_FILE_NAME=org.clan.app.desktop
|
||||
|
||||
# install desktop file on Linux
|
||||
set -eou pipefail
|
||||
DESKTOP_FILE_NAME=org.clan.app.desktop
|
||||
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
|
||||
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "macOS detected."
|
||||
mkdir -p ~/Applications
|
||||
ln -sf ~/.nix-profile/Applications/Clan\ App.app ~/Applications
|
||||
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f ~/Applications/Clan\ App.app
|
||||
else
|
||||
echo "Unsupported OS: $OSTYPE"
|
||||
fi
|
||||
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -91,8 +91,6 @@ mkShell {
|
||||
pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui"
|
||||
export NODE_PATH="$(pwd)/node_modules"
|
||||
export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH"
|
||||
|
||||
rm -rf .fonts || true
|
||||
cp -r ${self'.packages.fonts} .fonts
|
||||
chmod -R +w .fonts
|
||||
mkdir -p api
|
||||
|
||||
55
pkgs/clan-app/ui/package-lock.json
generated
55
pkgs/clan-app/ui/package-lock.json
generated
@@ -23,7 +23,6 @@
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-toast": "^0.5.0",
|
||||
"three": "^0.176.0",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3808,15 +3807,6 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -7538,15 +7528,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -8674,36 +8655,6 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
@@ -9317,12 +9268,6 @@
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-toast": "^0.5.0",
|
||||
"three": "^0.176.0",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
.fieldset {
|
||||
fieldset {
|
||||
@apply flex flex-col w-full;
|
||||
|
||||
legend {
|
||||
|
||||
@@ -35,10 +35,10 @@ export const Fieldset = (props: FieldsetProps) => {
|
||||
: props.children;
|
||||
|
||||
return (
|
||||
<div
|
||||
<fieldset
|
||||
role="group"
|
||||
class={cx("fieldset", { inverted: props.inverted })}
|
||||
aria-disabled={props.disabled || undefined}
|
||||
class={cx({ inverted: props.inverted })}
|
||||
disabled={props.disabled || false}
|
||||
>
|
||||
{props.legend && (
|
||||
<legend>
|
||||
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
221
pkgs/clan-app/ui/src/components/Form/MachineTags.css
Normal file
221
pkgs/clan-app/ui/src/components/Form/MachineTags.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,35 +1,29 @@
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { FieldProps } from "./Field";
|
||||
import {
|
||||
createEffect,
|
||||
on,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
splitProps,
|
||||
} from "solid-js";
|
||||
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
|
||||
import Icon from "../Icon/Icon";
|
||||
import cx from "classnames";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Tag } from "@/src/components/Tag/Tag";
|
||||
|
||||
import "./MachineTags.css";
|
||||
import { Label } from "@/src/components/Form/Label";
|
||||
import { Orienter } from "@/src/components/Form/Orienter";
|
||||
import { CollectionNode } from "@kobalte/core";
|
||||
import styles from "./MachineTags.module.css";
|
||||
|
||||
export interface MachineTag {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
new?: boolean;
|
||||
}
|
||||
|
||||
export type MachineTagsProps = FieldProps & {
|
||||
name: string;
|
||||
onChange: (values: string[]) => void;
|
||||
defaultValue?: string[];
|
||||
input: ComponentProps<"select">;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string[];
|
||||
defaultOptions?: string[];
|
||||
readonlyOptions?: string[];
|
||||
};
|
||||
@@ -50,12 +44,26 @@ const sortedOptions = (options: MachineTag[]) =>
|
||||
const sortedAndUniqueOptions = (options: MachineTag[]) =>
|
||||
sortedOptions(uniqueOptions(options));
|
||||
|
||||
export const MachineTags = (props: MachineTagsProps) => {
|
||||
const [local, rest] = splitProps(props, ["defaultValue"]);
|
||||
// customises how each option is displayed in the dropdown
|
||||
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
|
||||
return (
|
||||
<Combobox.Item item={props.item} class="item">
|
||||
<Combobox.ItemLabel>
|
||||
<Typography hierarchy="body" size="xs" weight="bold">
|
||||
{props.item.textValue}
|
||||
</Typography>
|
||||
</Combobox.ItemLabel>
|
||||
<Combobox.ItemIndicator class="item-indicator">
|
||||
<Icon icon="Checkmark" />
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
);
|
||||
};
|
||||
|
||||
// // convert default value string[] into MachineTag[]
|
||||
export const MachineTags = (props: MachineTagsProps) => {
|
||||
// convert default value string[] into MachineTag[]
|
||||
const defaultValue = sortedAndUniqueOptions(
|
||||
(local.defaultValue || []).map((value) => ({ value })),
|
||||
(props.defaultValue || []).map((value) => ({ value })),
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
// react when enter is pressed inside of the text input
|
||||
if (event.key === "Enter") {
|
||||
@@ -122,49 +85,22 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
|
||||
// get the current input value, exiting early if it's empty
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const trimmed = input.value.trim();
|
||||
if (!trimmed) return;
|
||||
if (input.value === "") return;
|
||||
|
||||
setAvailableOptions((curr) => {
|
||||
if (curr.find((option) => option.value === trimmed)) {
|
||||
return curr;
|
||||
}
|
||||
return [
|
||||
...curr,
|
||||
{
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
});
|
||||
setSelectedOptions((curr) => {
|
||||
if (curr.find((option) => option.value === trimmed)) {
|
||||
return curr;
|
||||
}
|
||||
return [
|
||||
...curr,
|
||||
{
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
setAvailableOptions((options) => {
|
||||
return options.map((option) => {
|
||||
return {
|
||||
...option,
|
||||
new: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
selectRef.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
selectRef.dispatchEvent(
|
||||
new Event("change", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
// reset the input value
|
||||
input.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Notify when selected options change
|
||||
createEffect(
|
||||
on(selectedOptions, (options) => {
|
||||
props.onChange(options.map((o) => o.value));
|
||||
}),
|
||||
);
|
||||
|
||||
const align = () => {
|
||||
if (props.readOnly) {
|
||||
return "center";
|
||||
@@ -176,19 +112,41 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
return (
|
||||
<Combobox<MachineTag>
|
||||
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]}
|
||||
defaultValue={defaultValue}
|
||||
value={selectedOptions()}
|
||||
options={availableOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="value"
|
||||
optionLabel="value"
|
||||
optionDisabled="disabled"
|
||||
itemComponent={ItemComponent(props.inverted || false)}
|
||||
placeholder="Start typing a name and press enter"
|
||||
onChange={() => {
|
||||
// noop, we handle this via the selectedOptions signal
|
||||
itemComponent={ItemComponent}
|
||||
placeholder="Enter a tag name"
|
||||
// triggerMode="focus"
|
||||
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()}>
|
||||
@@ -198,18 +156,11 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<Combobox.HiddenSelect
|
||||
multiple
|
||||
ref={(el) => {
|
||||
selectRef = el;
|
||||
}}
|
||||
/>
|
||||
<Combobox.HiddenSelect {...props.input} multiple />
|
||||
|
||||
<Combobox.Control<MachineTag>
|
||||
class={cx(styles.control, props.orientation)}
|
||||
>
|
||||
<Combobox.Control<MachineTag> class="control">
|
||||
{(state) => (
|
||||
<div class={styles.selectedOptions}>
|
||||
<div class="selected-options">
|
||||
<For each={state.selectedOptions()}>
|
||||
{(option) => (
|
||||
<Tag
|
||||
@@ -226,13 +177,7 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
icon={"Close"}
|
||||
size="0.5rem"
|
||||
inverted={inverted}
|
||||
onClick={() =>
|
||||
setSelectedOptions((curr) => {
|
||||
return curr.filter(
|
||||
(o) => o.value !== option.value,
|
||||
);
|
||||
})
|
||||
}
|
||||
onClick={() => state.remove(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -242,36 +187,27 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
)}
|
||||
</For>
|
||||
<Show when={!props.readOnly}>
|
||||
<Combobox.Trigger class={styles.trigger}>
|
||||
<Icon
|
||||
icon="Tag"
|
||||
color="secondary"
|
||||
inverted={props.inverted}
|
||||
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.Trigger>
|
||||
<div class="input-container">
|
||||
<Combobox.Input onKeyDown={onKeyDown} />
|
||||
<Combobox.Trigger class="trigger">
|
||||
<Combobox.Icon class="icon">
|
||||
<Icon
|
||||
icon="Expand"
|
||||
inverted={!props.inverted}
|
||||
size="100%"
|
||||
/>
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
</Orienter>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content
|
||||
class={cx(styles.comboboxContent, {
|
||||
[styles.comboboxContentInverted]: props.inverted,
|
||||
})}
|
||||
>
|
||||
<Combobox.Listbox class={styles.listbox} />
|
||||
<Combobox.Content class="machine-tags-content">
|
||||
<Combobox.Listbox class="listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
|
||||
@@ -76,19 +76,6 @@ div.form-field {
|
||||
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||
@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 {
|
||||
@@ -114,7 +101,7 @@ div.form-field {
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
@apply w-[0.6875rem] h-[0.6875rem];
|
||||
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||
import cx from "classnames";
|
||||
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Button } from "@kobalte/core/button";
|
||||
|
||||
const Examples = (props: TextInputProps) => (
|
||||
<div class="flex flex-col gap-8">
|
||||
@@ -85,38 +83,16 @@ export const Tooltip: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
...Tooltip.args,
|
||||
startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithStartComponent: Story = {
|
||||
args: {
|
||||
...Tooltip.args,
|
||||
startComponent: (props: { inverted?: boolean }) => (
|
||||
<Button>
|
||||
<Icon icon="EyeClose" color="quaternary" {...props} />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithEndComponent: Story = {
|
||||
args: {
|
||||
...Tooltip.args,
|
||||
endComponent: (props: { inverted?: boolean }) => (
|
||||
<Button>
|
||||
<Icon icon="EyeOpen" color="quaternary" {...props} />
|
||||
</Button>
|
||||
),
|
||||
icon: "Checkmark",
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
...WithIcon.args,
|
||||
...Icon.args,
|
||||
ghost: true,
|
||||
},
|
||||
};
|
||||
@@ -130,14 +106,14 @@ export const Invalid: Story = {
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
...WithIcon.args,
|
||||
...Icon.args,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
...WithIcon.args,
|
||||
...Icon.args,
|
||||
readOnly: true,
|
||||
defaultValue: "14/05/02",
|
||||
},
|
||||
|
||||
@@ -11,20 +11,12 @@ import "./TextInput.css";
|
||||
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onMount,
|
||||
splitProps,
|
||||
} from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
|
||||
export type TextInputProps = FieldProps &
|
||||
TextFieldRootProps & {
|
||||
icon?: IconVariant;
|
||||
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||
startComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||
endComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||
};
|
||||
|
||||
export const TextInput = (props: TextInputProps) => {
|
||||
@@ -36,39 +28,6 @@ export const TextInput = (props: TextInputProps) => {
|
||||
"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 (
|
||||
<TextField
|
||||
class={cx(
|
||||
@@ -91,11 +50,6 @@ export const TextInput = (props: TextInputProps) => {
|
||||
{...props}
|
||||
/>
|
||||
<div class="input-container">
|
||||
{props.startComponent && !props.readOnly && (
|
||||
<div ref={startComponentRef} class="start-component">
|
||||
{props.startComponent({ inverted: props.inverted })}
|
||||
</div>
|
||||
)}
|
||||
{props.icon && !props.readOnly && (
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
@@ -104,17 +58,9 @@ export const TextInput = (props: TextInputProps) => {
|
||||
/>
|
||||
)}
|
||||
<TextField.Input
|
||||
ref={inputRef}
|
||||
{...props.input}
|
||||
class={cx({
|
||||
"has-icon": props.icon && !props.readOnly,
|
||||
})}
|
||||
classList={{ "has-icon": props.icon && !props.readOnly }}
|
||||
/>
|
||||
{props.endComponent && !props.readOnly && (
|
||||
<div ref={endComponentRef} class="end-component">
|
||||
{props.endComponent({ inverted: props.inverted })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Orienter>
|
||||
</TextField>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.loader {
|
||||
@apply relative;
|
||||
@apply size-full;
|
||||
@apply w-4 h-4;
|
||||
|
||||
&.primary {
|
||||
& > div.wrapper > div.parent,
|
||||
@@ -15,18 +15,6 @@
|
||||
background: #0051ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.sizeDefault {
|
||||
@apply size-4;
|
||||
}
|
||||
|
||||
&.sizeLarge {
|
||||
@apply size-8;
|
||||
}
|
||||
|
||||
&.sizeExtraLarge {
|
||||
@apply size-12;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
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> = {
|
||||
title: "Components/Loader",
|
||||
component: LoaderExamples,
|
||||
component: Loader,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -7,23 +7,15 @@ export type Hierarchy = "primary" | "secondary";
|
||||
export interface LoaderProps {
|
||||
hierarchy?: Hierarchy;
|
||||
class?: string;
|
||||
size?: "default" | "l" | "xl";
|
||||
}
|
||||
|
||||
export const Loader = (props: LoaderProps) => {
|
||||
const size = () => props.size || "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cx(
|
||||
styles.loader,
|
||||
styles[props.hierarchy || "primary"],
|
||||
props.class,
|
||||
{
|
||||
[styles.sizeDefault]: size() === "default",
|
||||
[styles.sizeLarge]: size() === "l",
|
||||
[styles.sizeExtraLarge]: size() === "xl",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div class={styles.wrapper}>
|
||||
|
||||
@@ -20,9 +20,6 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
||||
// we will use css transform in the typography component to capitalize
|
||||
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 (
|
||||
<Switch>
|
||||
<Match when={!status()}>
|
||||
@@ -31,6 +28,9 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
||||
<Match when={status()}>
|
||||
<Badge
|
||||
class={cx("machine-status", {
|
||||
online: status() == "online",
|
||||
offline: status() == "offline",
|
||||
"out-of-sync": status() == "out_of_sync",
|
||||
"not-installed": status() == "not_installed",
|
||||
})}
|
||||
textValue={status()}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
div.sidebar-body {
|
||||
@apply py-4 px-2;
|
||||
/* full - (y padding) */
|
||||
height: calc(100% - 2rem);
|
||||
|
||||
@apply py-4 px-2 h-full;
|
||||
@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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
|
||||
linear-gradient(
|
||||
@@ -21,14 +20,13 @@ div.sidebar-body {
|
||||
@apply backdrop-blur-sm;
|
||||
|
||||
.accordion {
|
||||
@apply w-full mb-4 h-full flex flex-col justify-start gap-4;
|
||||
@apply w-full mb-4;
|
||||
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
& > .item {
|
||||
max-height: 50%;
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
@@ -60,13 +58,9 @@ div.sidebar-body {
|
||||
}
|
||||
|
||||
& > .content {
|
||||
@apply flex flex-col;
|
||||
@apply overflow-hidden flex flex-col;
|
||||
@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);
|
||||
|
||||
&[data-expanded] {
|
||||
|
||||
@@ -3,14 +3,12 @@ import { A } from "@solidjs/router";
|
||||
import { Accordion } from "@kobalte/core/accordion";
|
||||
import Icon from "../Icon/Icon";
|
||||
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 { buildMachinePath, buildServicePath } from "@/src/hooks/clan";
|
||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import { SidebarProps } from "./Sidebar";
|
||||
import { Button } from "../Button/Button";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { Instance } from "@/src/workflows/Service/models";
|
||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
interface MachineProps {
|
||||
clanURI: string;
|
||||
@@ -34,19 +32,19 @@ const MachineRoute = (props: MachineProps) => {
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted
|
||||
inverted={true}
|
||||
>
|
||||
{props.name}
|
||||
</Typography>
|
||||
<MachineStatus status={status()} />
|
||||
</div>
|
||||
<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
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="s"
|
||||
inverted
|
||||
inverted={true}
|
||||
color="primary"
|
||||
>
|
||||
{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) => {
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
}
|
||||
|
||||
const sectionLabels = (props.staticSections || []).map(
|
||||
(section) => section.title,
|
||||
);
|
||||
|
||||
// controls which sections are open by default
|
||||
// we want them all to be open by default
|
||||
const defaultAccordionValues = ["machines", "services", ...sectionLabels];
|
||||
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
||||
|
||||
return (
|
||||
<div class="sidebar-body">
|
||||
@@ -248,8 +78,42 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
multiple
|
||||
defaultValue={defaultAccordionValues}
|
||||
>
|
||||
<Machines />
|
||||
<Services />
|
||||
<Accordion.Item class="item" value="your-machines">
|
||||
<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}>
|
||||
{(section) => (
|
||||
@@ -261,7 +125,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
inverted
|
||||
inverted={true}
|
||||
color="tertiary"
|
||||
>
|
||||
{section.title}
|
||||
@@ -269,7 +133,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
<Icon
|
||||
icon="CaretDown"
|
||||
color="tertiary"
|
||||
inverted
|
||||
inverted={true}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
@@ -284,7 +148,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted
|
||||
inverted={true}
|
||||
>
|
||||
{link.label}
|
||||
</Typography>
|
||||
|
||||
@@ -3,12 +3,12 @@ import Icon from "@/src/components/Icon/Icon";
|
||||
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
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 { setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Button } from "../Button/Button";
|
||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
export const SidebarHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -17,7 +17,11 @@ export const SidebarHeader = () => {
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
|
||||
// get information about the current active clan
|
||||
const ctx = useClanContext();
|
||||
const ctx = useContext(ClanContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("SidebarContext not found");
|
||||
}
|
||||
|
||||
const clanChar = () =>
|
||||
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
|
||||
|
||||
@@ -13,7 +13,6 @@ import * as v from "valibot";
|
||||
import { splitProps } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||
import { setValue } from "@modular-forms/solid";
|
||||
|
||||
type Story = StoryObj<SidebarPaneProps>;
|
||||
|
||||
@@ -138,21 +137,18 @@ export const Default: Story = {
|
||||
console.log("saving tags", values);
|
||||
}}
|
||||
>
|
||||
{({ editing, Field, formStore }) => (
|
||||
{({ editing, Field }) => (
|
||||
<Field name="tags" type="string[]">
|
||||
{(field, props) => (
|
||||
{(field, input) => (
|
||||
<MachineTags
|
||||
{...splitProps(field, ["value"])[1]}
|
||||
size="s"
|
||||
onChange={(newVal) => {
|
||||
// Workaround for now, until we manage to use native events
|
||||
setValue(formStore, field.name, newVal);
|
||||
}}
|
||||
inverted
|
||||
required
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
defaultValue={field.value}
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
|
||||
class?: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
subHeader?: JSX.Element;
|
||||
subHeader?: () => JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
||||
</KButton>
|
||||
</div>
|
||||
<Show when={props.subHeader}>
|
||||
<div class="sub-header">{props.subHeader}</div>
|
||||
<div class="sub-header">{props.subHeader!()}</div>
|
||||
</Show>
|
||||
<div class="body">{props.children}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createSignal, JSX, Show } from "solid-js";
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
FormStore,
|
||||
getErrors,
|
||||
Maybe,
|
||||
PartialValues,
|
||||
@@ -26,7 +25,6 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
|
||||
children: (ctx: {
|
||||
editing: boolean;
|
||||
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
|
||||
formStore: FormStore<FormValues>;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
@@ -53,8 +51,6 @@ export function SidebarSectionForm<
|
||||
};
|
||||
|
||||
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
||||
console.log("Submitting SidebarForm", values);
|
||||
|
||||
await props.onSubmit(values);
|
||||
setEditing(false);
|
||||
};
|
||||
@@ -113,7 +109,7 @@ export function SidebarSectionForm<
|
||||
</Typography>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children({ editing: editing(), Field, formStore })}
|
||||
{props.children({ editing: editing(), Field })}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
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 { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import styles from "./SidebarSectionInstall.module.css";
|
||||
import { Alert } from "../Alert/Alert";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
export interface SidebarSectionInstallProps {
|
||||
clanURI: string;
|
||||
@@ -13,8 +12,8 @@ export interface SidebarSectionInstallProps {
|
||||
}
|
||||
|
||||
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||
const ctx = useClanContext();
|
||||
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||
|
||||
const [showInstall, setShowModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
@@ -33,12 +32,7 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||
<InstallModal
|
||||
open={showInstall()}
|
||||
machineName={useMachineName()}
|
||||
onClose={async () => {
|
||||
// refresh some queries
|
||||
ctx.machinesQuery.refetch();
|
||||
ctx.serviceInstancesQuery.refetch();
|
||||
setShowModal(false);
|
||||
}}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
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 decodeBase64 = (value: string) => window.atob(value);
|
||||
@@ -30,47 +30,6 @@ export const buildClanPath = (clanURI: string) => {
|
||||
export const buildMachinePath = (clanURI: string, name: string) =>
|
||||
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) => {
|
||||
const path = buildClanPath(clanURI);
|
||||
console.log("Navigating to clan", clanURI, path);
|
||||
@@ -105,21 +64,7 @@ export const machineNameParam = (params: Params) => {
|
||||
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 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 => {
|
||||
const params = useParams();
|
||||
|
||||
@@ -25,9 +25,6 @@ export type MachineStatus = MachineState["status"];
|
||||
export type ListMachines = SuccessData<"list_machines">;
|
||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||
|
||||
export type ListServiceModules = SuccessData<"list_service_modules">;
|
||||
export type ListServiceInstances = SuccessData<"list_service_instances">;
|
||||
|
||||
export interface MachineDetail {
|
||||
tags: Tags;
|
||||
machine: Machine;
|
||||
@@ -50,7 +47,7 @@ export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: [...clanKey(clanURI), "machines"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
const api = client.fetch("list_machines", {
|
||||
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) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetail>(() => ({
|
||||
queryKey: [machineKey(clanURI, machineName)],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryFn: async () => {
|
||||
const [tagsCall, machineCall, schemaCall] = [
|
||||
client.fetch("list_tags", {
|
||||
@@ -131,7 +122,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
|
||||
export const useTags = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: [...clanKey(clanURI), "tags"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "tags"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("list_tags", {
|
||||
flake: {
|
||||
@@ -151,7 +142,7 @@ export const useTags = (clanURI: string) => {
|
||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: [...machineKey(clanURI, machineName), "state"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
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 = (
|
||||
clanURI: string,
|
||||
machineName: string,
|
||||
) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetails>(() => ({
|
||||
queryKey: [machineKey(clanURI, machineName), "details"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_details", {
|
||||
machine: {
|
||||
@@ -258,7 +201,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
|
||||
export const useClanDetailsQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ClanDetails>(() => ({
|
||||
queryKey: [...clanKey(clanURI), "details"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
persister: ClanDetailsPersister.persisterFn,
|
||||
queryFn: async () => {
|
||||
const args = {
|
||||
@@ -309,8 +252,7 @@ export const useClanListQuery = (
|
||||
|
||||
return useQueries(() => ({
|
||||
queries: clanURIs.map((clanURI) => {
|
||||
// @BMG: Is duplicating query key intentional?
|
||||
const queryKey = [...clanKey(clanURI), "details"];
|
||||
const queryKey = ["clans", encodeBase64(clanURI), "details"];
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
@@ -379,7 +321,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
|
||||
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineFlashOptions>(() => ({
|
||||
queryKey: ["flash_options"],
|
||||
queryKey: ["clans", "machine_flash_options"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_flash_options", {});
|
||||
const result = await call.result;
|
||||
@@ -543,7 +485,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
|
||||
export const useServiceModules = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: [...clanKey(clanUri), "service_modules"],
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_modules", {
|
||||
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 ServiceInstances = SuccessData<"list_service_instances">;
|
||||
export const useServiceInstances = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: [...clanKey(clanUri), "service_instances"],
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
flake: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
@@ -11,83 +11,87 @@ import {
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import {
|
||||
buildClanPath,
|
||||
buildMachinePath,
|
||||
maybeUseMachineName,
|
||||
useClanURI,
|
||||
useMachineName,
|
||||
} from "@/src/hooks/clan";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||
import {
|
||||
ClanDetails,
|
||||
ListServiceInstances,
|
||||
MachinesQueryResult,
|
||||
useClanDetailsQuery,
|
||||
useClanListQuery,
|
||||
useMachinesQuery,
|
||||
useServiceInstancesQuery,
|
||||
} from "@/src/hooks/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Splash } from "@/src/scene/splash";
|
||||
import cx from "classnames";
|
||||
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 { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
machinesQuery: MachinesQueryResult;
|
||||
activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
export type WorldMode = "default" | "select" | "service" | "create" | "move";
|
||||
|
||||
function createClanContext(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
|
||||
) {
|
||||
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
|
||||
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const allClansQueries = [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,
|
||||
};
|
||||
isLoading(): boolean;
|
||||
isError(): boolean;
|
||||
}
|
||||
|
||||
const ClanContext = createContext<
|
||||
ReturnType<typeof createClanContext> | undefined
|
||||
>();
|
||||
class DefaultClanContext implements ClanContextProps {
|
||||
public readonly clanURI: string;
|
||||
|
||||
export const useClanContext = () => {
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
public readonly activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
public readonly allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
public readonly machinesQuery: MachinesQueryResult;
|
||||
|
||||
allQueries: UseQueryResult[];
|
||||
|
||||
constructor(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||
) {
|
||||
this.clanURI = clanURI;
|
||||
this.machinesQuery = machinesQuery;
|
||||
|
||||
this.activeClanQuery = activeClanQuery;
|
||||
this.otherClanQueries = otherClanQueries;
|
||||
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||
|
||||
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
isLoading(): boolean {
|
||||
return this.allQueries.some((q) => q.isLoading);
|
||||
}
|
||||
|
||||
isError(): boolean {
|
||||
return this.activeClanQuery.isError;
|
||||
}
|
||||
}
|
||||
|
||||
export const ClanContext = createContext<ClanContextProps>();
|
||||
|
||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
const clanURI = useClanURI();
|
||||
@@ -105,18 +109,18 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
);
|
||||
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
|
||||
|
||||
const ctx = createClanContext(
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
serviceInstancesQuery,
|
||||
);
|
||||
|
||||
return (
|
||||
<ClanContext.Provider value={ctx}>
|
||||
<ClanContext.Provider
|
||||
value={
|
||||
new DefaultClanContext(
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={cx(styles.sidebarContainer, {
|
||||
[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 ctx = useClanContext();
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showService, setShowService] = createSignal(false);
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
@@ -142,11 +202,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const onCreate = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ctx.setShowAddMachine(true);
|
||||
setShowModal(true);
|
||||
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<
|
||||
{ title: string; description: string } | undefined
|
||||
>();
|
||||
@@ -174,8 +268,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const selected = ids.values().next().value;
|
||||
if (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 (
|
||||
<>
|
||||
<Show when={loadingError()}>
|
||||
<ListClansModal error={loadingError()} />
|
||||
</Show>
|
||||
<Show when={ctx.showAddMachine()}>
|
||||
<AddMachine
|
||||
onCreated={async (id) => {
|
||||
const promise = currentPromise();
|
||||
if (promise) {
|
||||
await ctx.machinesQuery.refetch();
|
||||
promise.resolve({ id });
|
||||
setCurrentPromise(null);
|
||||
}
|
||||
}}
|
||||
<Show when={showModal()}>
|
||||
<MockCreateMachine
|
||||
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>
|
||||
@@ -231,19 +365,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
isLoading={ctx.isLoading()}
|
||||
cubesQuery={ctx.machinesQuery}
|
||||
toolbarPopup={
|
||||
<Show when={ctx.worldMode() === "service"}>
|
||||
<Show
|
||||
when={location.pathname.includes("/services/")}
|
||||
fallback={
|
||||
<SelectService
|
||||
onClose={() => {
|
||||
ctx.setWorldMode("select");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
<Show when={showService()}>
|
||||
<ServiceWorkflow
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
setShowService(false);
|
||||
setWorldMode("default");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { SectionGeneral } from "./SectionGeneral";
|
||||
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||
|
||||
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) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,16 +20,12 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
navigateToClan(navigate, clanURI);
|
||||
};
|
||||
|
||||
const Sections = () => {
|
||||
const machineName = useMachineName();
|
||||
const sidebarPane = (machineName: string) => {
|
||||
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
|
||||
// for that reason we pass in this common submit handler to each machine sub section
|
||||
const onSubmit = async (values: Partial<MachineModel>) => {
|
||||
console.log("saving tags", values);
|
||||
const call = callApi("set_machine", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
@@ -56,43 +51,25 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarSectionInstall
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
<SidebarSectionUpdate
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
<SectionServices />
|
||||
</>
|
||||
<div class={styles.sidebarPaneContainer}>
|
||||
<SidebarPane
|
||||
title={machineName}
|
||||
onClose={onClose}
|
||||
subHeader={() => (
|
||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||
)}
|
||||
>
|
||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</SidebarPane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={useMachineName()}>
|
||||
<div class={styles.sidebarPaneContainer}>
|
||||
<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 when={useMachineName()} keyed>
|
||||
{sidebarPane(useMachineName())}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
|
||||
import { pick } from "@/src/util";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||
import { setValue } from "@modular-forms/solid";
|
||||
|
||||
const schema = v.object({
|
||||
tags: v.pipe(v.optional(v.array(v.string()))),
|
||||
@@ -33,7 +32,7 @@ export const SectionTags = (props: SectionTags) => {
|
||||
|
||||
const options = () => {
|
||||
if (!machineQuery.isSuccess) {
|
||||
return [];
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
// 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}
|
||||
initialValues={initialValues()}
|
||||
>
|
||||
{({ editing, Field, formStore }) => (
|
||||
{({ editing, Field }) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field name="tags" type="string[]">
|
||||
{(field, input) => (
|
||||
@@ -73,10 +72,7 @@ export const SectionTags = (props: SectionTags) => {
|
||||
defaultValue={field.value}
|
||||
defaultOptions={options()[0]}
|
||||
readonlyOptions={options()[1]}
|
||||
onChange={(newVal) => {
|
||||
// Workaround for now, until we manage to use native events
|
||||
setValue(formStore, field.name, newVal);
|
||||
}}
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import type { RouteDefinition } from "@solidjs/router/dist/types";
|
||||
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
||||
import { Clan } from "@/src/routes/Clan/Clan";
|
||||
import { Machine } from "@/src/routes/Machine/Machine";
|
||||
import { Service } from "@/src/routes/Service/Service";
|
||||
|
||||
export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
@@ -31,15 +30,6 @@ export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/machines/:machineName",
|
||||
component: Machine,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/services/:name/:id",
|
||||
component: Service,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -27,7 +27,6 @@ export class MachineManager {
|
||||
machinesQueryResult: MachinesQueryResult,
|
||||
selectedIds: Accessor<Set<string>>,
|
||||
setMachinePos: (id: string, position: [number, number] | null) => void,
|
||||
camera: THREE.Camera,
|
||||
) {
|
||||
this.machinePositionsSignal = machinePositionsSignal;
|
||||
|
||||
@@ -40,10 +39,10 @@ export class MachineManager {
|
||||
|
||||
const actualIds = Object.keys(machinesQueryResult.data);
|
||||
const machinePositions = machinePositionsSignal();
|
||||
|
||||
// Remove stale
|
||||
for (const id of Object.keys(machinePositions)) {
|
||||
if (!actualIds.includes(id)) {
|
||||
console.log("Removing stale machine", id);
|
||||
setMachinePos(id, null);
|
||||
}
|
||||
}
|
||||
@@ -62,11 +61,10 @@ export class MachineManager {
|
||||
//
|
||||
createEffect(() => {
|
||||
const positions = machinePositionsSignal();
|
||||
if (!positions) return;
|
||||
|
||||
// Remove machines from scene
|
||||
for (const [id, repr] of this.machines) {
|
||||
if (!Object.keys(positions).includes(id)) {
|
||||
if (!(id in positions)) {
|
||||
repr.dispose(scene);
|
||||
this.machines.delete(id);
|
||||
}
|
||||
@@ -83,7 +81,6 @@ export class MachineManager {
|
||||
id,
|
||||
selectedIds,
|
||||
highlightGroups,
|
||||
camera,
|
||||
);
|
||||
this.machines.set(id, repr);
|
||||
scene.add(repr.group);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import * as THREE from "three";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||
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
|
||||
const BASE_SIZE = 0.9;
|
||||
@@ -22,71 +20,6 @@ const BASE_EMISSIVE = 0x0c0c0c;
|
||||
const BASE_SELECTED_COLOR = 0x69b0e3;
|
||||
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 {
|
||||
public id: string;
|
||||
public group: THREE.Group;
|
||||
@@ -95,7 +28,6 @@ export class MachineRepr {
|
||||
private baseMesh: THREE.Mesh;
|
||||
private geometry: THREE.BoxGeometry;
|
||||
private material: THREE.MeshPhongMaterial;
|
||||
private camera: THREE.Camera;
|
||||
|
||||
private disposeRoot: () => void;
|
||||
|
||||
@@ -106,25 +38,34 @@ export class MachineRepr {
|
||||
id: string,
|
||||
selectedSignal: Accessor<Set<string>>,
|
||||
highlightGroups: Record<string, Set<string>>, // Reactive store
|
||||
camera: THREE.Camera,
|
||||
) {
|
||||
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 = cubeMesh;
|
||||
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
|
||||
this.cubeMesh.castShadow = true;
|
||||
this.cubeMesh.receiveShadow = true;
|
||||
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.geometry = geometry;
|
||||
this.material = material;
|
||||
|
||||
const label = this.createLabel(id);
|
||||
this.cubeMesh.add(label);
|
||||
|
||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||
color: BASE_COLOR,
|
||||
color: BASE_COLOR, // any color you like
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
@@ -141,7 +82,6 @@ export class MachineRepr {
|
||||
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
|
||||
|
||||
this.group = new THREE.Group();
|
||||
this.group.add(label);
|
||||
this.group.add(this.cubeMesh);
|
||||
this.group.add(this.baseMesh);
|
||||
this.group.add(shadowPlane);
|
||||
@@ -158,6 +98,8 @@ export class MachineRepr {
|
||||
const highlightedGroups = groups
|
||||
.filter(([, ids]) => ids.has(this.id))
|
||||
.map(([name]) => name);
|
||||
|
||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
@@ -174,6 +116,9 @@ export class MachineRepr {
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||
);
|
||||
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||
// );
|
||||
|
||||
renderLoop.requestRender();
|
||||
},
|
||||
@@ -198,85 +143,30 @@ export class MachineRepr {
|
||||
renderLoop.requestRender();
|
||||
}
|
||||
|
||||
private createCubeBase(
|
||||
color: THREE.ColorRepresentation,
|
||||
emissive: THREE.ColorRepresentation,
|
||||
geometry: THREE.BoxGeometry,
|
||||
) {
|
||||
const baseMaterial = new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
emissive,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
});
|
||||
const base = new THREE.Mesh(geometry, baseMaterial);
|
||||
base.position.set(0, BASE_HEIGHT / 2, 0);
|
||||
base.receiveShadow = false;
|
||||
return base;
|
||||
}
|
||||
|
||||
private createLabel(id: string) {
|
||||
const group = new THREE.Group();
|
||||
// 0x162324
|
||||
// const text = new Text();
|
||||
// text.text = id;
|
||||
// text.font = ttf;
|
||||
// text.fontSize = 0.1;
|
||||
// text.color = 0xffffff;
|
||||
// text.anchorX = "center";
|
||||
// text.anchorY = "middle";
|
||||
// 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, {
|
||||
font: new FontLoader().parse(jsonfont),
|
||||
size: 0.09,
|
||||
depth: 0.001,
|
||||
curveSegments: 12,
|
||||
bevelEnabled: false,
|
||||
});
|
||||
|
||||
const text = new THREE.Mesh(textGeo, textMaterial);
|
||||
textGeo.computeBoundingBox();
|
||||
|
||||
const bbox = textGeo.boundingBox;
|
||||
if (bbox) {
|
||||
const xMid = -0.5 * (bbox.max.x - bbox.min.x);
|
||||
// const yMid = -0.5 * (bbox.max.y - bbox.min.y);
|
||||
// const zMid = -0.5 * (bbox.max.z - bbox.min.z);
|
||||
|
||||
// Translate geometry so center is at origin / baseline aligned with y=0
|
||||
textGeo.translate(xMid, -0.035, 0);
|
||||
}
|
||||
|
||||
// --- Background (rounded rect) ---
|
||||
const padding = 0.04;
|
||||
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1;
|
||||
const bgWidth = textWidth + 10 * padding;
|
||||
// const bgWidth = text.text.length * 0.07 + padding;
|
||||
const bgHeight = 0.1 + 2 * padding;
|
||||
const radius = 0.02;
|
||||
|
||||
const bgShape = roundedRectShape(bgWidth, bgHeight, radius);
|
||||
const bgGeom = new THREE.ShapeGeometry(bgShape);
|
||||
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 });
|
||||
const bg = new THREE.Mesh(bgGeom, bgMat);
|
||||
bg.position.set(0, 0, -0.01);
|
||||
|
||||
// --- Arrow (triangle pointing down) ---
|
||||
const arrowShape = new THREE.Shape();
|
||||
arrowShape.moveTo(-0.05, 0);
|
||||
arrowShape.lineTo(0.05, 0);
|
||||
arrowShape.lineTo(0, -0.05);
|
||||
arrowShape.closePath();
|
||||
|
||||
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
|
||||
const arrow = new THREE.Mesh(arrowGeom, bgMat);
|
||||
arrow.position.set(0, -bgHeight / 2, -0.001);
|
||||
|
||||
// --- Group ---
|
||||
group.add(bg);
|
||||
group.add(arrow);
|
||||
group.add(text);
|
||||
|
||||
// Position above cube
|
||||
group.position.set(0, CUBE_SIZE + 0.3, 0);
|
||||
|
||||
// Billboard
|
||||
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
|
||||
group.quaternion.copy(this.camera.quaternion);
|
||||
|
||||
return group;
|
||||
const div = document.createElement("div");
|
||||
div.className = "machine-label";
|
||||
div.textContent = id;
|
||||
const label = new CSS2DObject(div);
|
||||
label.position.set(0, CUBE_SIZE + 0.1, 0);
|
||||
return label;
|
||||
}
|
||||
|
||||
dispose(scene: THREE.Scene) {
|
||||
@@ -286,13 +176,12 @@ export class MachineRepr {
|
||||
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
|
||||
this.group.clear();
|
||||
|
||||
for (const child of this.cubeMesh.children) {
|
||||
if (child instanceof THREE.Mesh)
|
||||
(child.material as THREE.Material).dispose();
|
||||
|
||||
if (child instanceof CSS2DObject) child.element.remove();
|
||||
|
||||
if (child instanceof THREE.Object3D) child.remove();
|
||||
}
|
||||
(this.baseMesh.material as THREE.Material).dispose();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Scene, Camera, WebGLRenderer } from "three";
|
||||
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
|
||||
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
/**
|
||||
* Private class to manage the render loop
|
||||
* @internal
|
||||
@@ -93,18 +93,6 @@ class RenderLoop {
|
||||
|
||||
this.renderer.render(this.bgScene, this.bgCamera);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.cubes-scene-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||
<Show when={show()}> */
|
||||
.toolbar-container {
|
||||
@apply absolute bottom-10 z-30 left-1/2;
|
||||
@apply absolute bottom-10 z-10 w-full;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
onMount,
|
||||
on,
|
||||
JSX,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import "./cubes.css";
|
||||
|
||||
@@ -22,39 +21,6 @@ import { Accessor } from "solid-js";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
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) {
|
||||
for (const child of group.children) {
|
||||
@@ -95,6 +61,12 @@ export function useMachineClick() {
|
||||
return lastClickedMachine;
|
||||
}
|
||||
|
||||
/*Gloabl signal*/
|
||||
const [worldMode, setWorldMode] = createSignal<
|
||||
"default" | "select" | "service" | "create"
|
||||
>("default");
|
||||
export { worldMode, setWorldMode };
|
||||
|
||||
export function CubeScene(props: {
|
||||
cubesQuery: MachinesQueryResult;
|
||||
onCreate: () => Promise<{ id: string }>;
|
||||
@@ -106,8 +78,6 @@ export function CubeScene(props: {
|
||||
clanURI: string;
|
||||
toolbarPopup?: JSX.Element;
|
||||
}) {
|
||||
const ctx = useClanContext();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.OrthographicCamera;
|
||||
@@ -117,8 +87,7 @@ export function CubeScene(props: {
|
||||
let controls: MapControls;
|
||||
// Raycaster for clicking
|
||||
const raycaster = new THREE.Raycaster();
|
||||
let actionBase: THREE.Mesh | undefined;
|
||||
let actionMachine: THREE.Group | undefined;
|
||||
let initBase: THREE.Mesh | undefined;
|
||||
|
||||
// Create background scene
|
||||
const bgScene = new THREE.Scene();
|
||||
@@ -129,27 +98,16 @@ export function CubeScene(props: {
|
||||
let sharedCubeGeometry: THREE.BoxGeometry;
|
||||
let sharedBaseGeometry: THREE.BoxGeometry;
|
||||
|
||||
let machineManager: MachineManager;
|
||||
|
||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||
"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 [cameraInfo, setCameraInfo] = createSignal({
|
||||
position: { x: 0, y: 0, z: 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
|
||||
const GRID_SIZE = 1;
|
||||
@@ -165,10 +123,8 @@ export function CubeScene(props: {
|
||||
const BASE_COLOR = 0xecfdff;
|
||||
const BASE_EMISSIVE = 0x0c0c0c;
|
||||
|
||||
const ACTION_BASE_COLOR = 0x636363;
|
||||
|
||||
const CREATE_BASE_COLOR = 0x636363;
|
||||
const CREATE_BASE_EMISSIVE = 0xc5fad7;
|
||||
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
|
||||
|
||||
function createCubeBase(
|
||||
cube_pos: [number, number, number],
|
||||
@@ -189,6 +145,12 @@ export function CubeScene(props: {
|
||||
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 initialSphericalCameraPosition = new THREE.Spherical();
|
||||
initialSphericalCameraPosition.setFromVector3(
|
||||
@@ -311,13 +273,6 @@ export function CubeScene(props: {
|
||||
bgCamera,
|
||||
);
|
||||
|
||||
// controls.addEventListener("start", (e) => {
|
||||
// setIsDragging(true);
|
||||
// });
|
||||
// controls.addEventListener("end", (e) => {
|
||||
// setIsDragging(false);
|
||||
// });
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||
scene.add(ambientLight);
|
||||
@@ -385,32 +340,15 @@ export function CubeScene(props: {
|
||||
);
|
||||
|
||||
// Important create CubeBase depends on sharedBaseGeometry
|
||||
actionBase = createCubeBase(
|
||||
initBase = createCubeBase(
|
||||
[1, BASE_HEIGHT / 2, 1],
|
||||
1,
|
||||
ACTION_BASE_COLOR,
|
||||
CREATE_BASE_COLOR,
|
||||
CREATE_BASE_EMISSIVE,
|
||||
);
|
||||
actionBase.visible = false;
|
||||
initBase.visible = false;
|
||||
|
||||
scene.add(actionBase);
|
||||
|
||||
function createActionMachine() {
|
||||
const { baseMesh, cubeMesh, material, baseMaterial } =
|
||||
createMachineMesh();
|
||||
const group = new THREE.Group();
|
||||
group.add(baseMesh);
|
||||
group.add(cubeMesh);
|
||||
// group.scale.set(0.75, 0.75, 0.75);
|
||||
material.opacity = 0.6;
|
||||
baseMaterial.opacity = 0.3;
|
||||
baseMaterial.emissive.set(MOVE_BASE_EMISSIVE);
|
||||
// Hide until needed
|
||||
group.visible = false;
|
||||
return group;
|
||||
}
|
||||
actionMachine = createActionMachine();
|
||||
scene.add(actionMachine);
|
||||
scene.add(initBase);
|
||||
|
||||
// const spherical = new THREE.Spherical();
|
||||
// spherical.setFromVector3(camera.position);
|
||||
@@ -437,11 +375,11 @@ export function CubeScene(props: {
|
||||
updateCameraInfo();
|
||||
|
||||
createEffect(
|
||||
on(ctx.worldMode, (mode) => {
|
||||
on(worldMode, (mode) => {
|
||||
if (mode === "create") {
|
||||
actionBase!.visible = true;
|
||||
initBase!.visible = true;
|
||||
} else {
|
||||
actionBase!.visible = false;
|
||||
initBase!.visible = false;
|
||||
}
|
||||
renderLoop.requestRender();
|
||||
}),
|
||||
@@ -449,21 +387,20 @@ export function CubeScene(props: {
|
||||
|
||||
const registry = new ObjectRegistry();
|
||||
|
||||
machineManager = new MachineManager(
|
||||
const machineManager = new MachineManager(
|
||||
scene,
|
||||
registry,
|
||||
props.sceneStore,
|
||||
props.cubesQuery,
|
||||
props.selectedIds,
|
||||
props.setMachinePos,
|
||||
camera,
|
||||
);
|
||||
|
||||
// Click handler:
|
||||
// - Select/deselects a cube in mode
|
||||
// - Creates a new cube in "create" mode
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (ctx.worldMode() === "create") {
|
||||
if (worldMode() === "create") {
|
||||
props
|
||||
.onCreate()
|
||||
.then(({ id }) => {
|
||||
@@ -479,20 +416,11 @@ export function CubeScene(props: {
|
||||
console.error("Error creating cube:", error);
|
||||
})
|
||||
.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 mouse = new THREE.Vector2(
|
||||
@@ -504,20 +432,18 @@ export function CubeScene(props: {
|
||||
const intersects = raycaster.intersectObjects(
|
||||
Array.from(machineManager.machines.values().map((m) => m.group)),
|
||||
);
|
||||
console.log("Intersects:", intersects);
|
||||
if (intersects.length > 0) {
|
||||
const id = intersects.find((i) => i.object.userData?.id)?.object
|
||||
.userData.id;
|
||||
console.log("Clicked on cube:", intersects);
|
||||
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
|
||||
} else {
|
||||
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();
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
// For debugging,
|
||||
// TODO: Remove in production
|
||||
window.addEventListener(
|
||||
"contextmenu",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
// Initial render
|
||||
renderLoop.requestRender();
|
||||
@@ -645,12 +512,12 @@ export function CubeScene(props: {
|
||||
renderer.domElement.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
|
||||
if (actionBase) {
|
||||
actionBase.geometry.dispose();
|
||||
if (Array.isArray(actionBase.material)) {
|
||||
actionBase.material.forEach((material) => material.dispose());
|
||||
if (initBase) {
|
||||
initBase.geometry.dispose();
|
||||
if (Array.isArray(initBase.material)) {
|
||||
initBase.material.forEach((material) => material.dispose());
|
||||
} 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) => {
|
||||
setPositionMode("grid");
|
||||
ctx.setWorldMode("create");
|
||||
setWorldMode("create");
|
||||
renderLoop.requestRender();
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return;
|
||||
if (worldMode() !== "create") return;
|
||||
if (!initBase) return;
|
||||
|
||||
const actionRepr =
|
||||
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
|
||||
initBase.visible = true;
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
@@ -723,71 +548,41 @@ export function CubeScene(props: {
|
||||
if (intersects.length > 0) {
|
||||
const point = intersects[0].point;
|
||||
|
||||
const snapped = snapToGrid(point);
|
||||
if (!snapped) 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
|
||||
if (props.sceneStore()) {
|
||||
const positions = Object.values(props.sceneStore());
|
||||
const intersects = positions.some(
|
||||
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
|
||||
);
|
||||
if (intersects) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(actionRepr.position.z - snapped.z) > 0.01
|
||||
Math.abs(initBase.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(initBase.position.z - snapped.z) > 0.01
|
||||
) {
|
||||
// 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
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={contextOpen()}>
|
||||
<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="cubes-scene-container" ref={(el) => (container = el)} />
|
||||
<div class="toolbar-container">
|
||||
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||
{props.toolbarPopup}
|
||||
@@ -797,29 +592,30 @@ export function CubeScene(props: {
|
||||
description="Select machine"
|
||||
name="Select"
|
||||
icon="Cursor"
|
||||
onClick={() => ctx.setWorldMode("select")}
|
||||
selected={ctx.worldMode() === "select"}
|
||||
onClick={() =>
|
||||
setWorldMode((v) => (v === "select" ? "default" : "select"))
|
||||
}
|
||||
selected={worldMode() === "select"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
description="Create new machine"
|
||||
name="new-machine"
|
||||
icon="NewMachine"
|
||||
onClick={onAddClick}
|
||||
selected={ctx.worldMode() === "create"}
|
||||
selected={worldMode() === "create"}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<ToolbarButton
|
||||
description="Add new Service"
|
||||
name="modules"
|
||||
icon="Services"
|
||||
selected={ctx.worldMode() === "service"}
|
||||
selected={worldMode() === "service"}
|
||||
onClick={() => {
|
||||
ctx.navigateToRoot();
|
||||
ctx.setWorldMode("service");
|
||||
setWorldMode((v) => (v === "service" ? "default" : "service"));
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="Update"
|
||||
icon="Reload"
|
||||
name="Reload"
|
||||
description="Reload machines"
|
||||
onClick={() => machinesQuery.refetch()}
|
||||
|
||||
@@ -6,35 +6,3 @@ export const pick = <T, K extends keyof T>(obj: T, keys: K[]): 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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user