Compare commits
107 Commits
revers-upd
...
ui/fix-ins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ea536d46 | ||
|
|
dba166cc8a | ||
|
|
21b872a1c9 | ||
|
|
be48ffe724 | ||
|
|
7673b72991 | ||
|
|
823114435a | ||
|
|
e7efbb701b | ||
|
|
30d9c86015 | ||
|
|
313b77be79 | ||
|
|
6229e62281 | ||
|
|
49ff4da6be | ||
|
|
6d6521803d | ||
|
|
afd7bfc8c0 | ||
|
|
88fa3dff83 | ||
|
|
629ef65ce5 | ||
|
|
92151331f3 | ||
|
|
67dcd45dd5 | ||
|
|
95a4a69ffb | ||
|
|
88343ce523 | ||
|
|
fd9dd6f872 | ||
|
|
aaaa310c7f | ||
|
|
ffbf22eb60 | ||
|
|
8d3e0d2209 | ||
|
|
c05a890d50 | ||
|
|
03458ffbd8 | ||
|
|
ea098048c8 | ||
|
|
838ed6ead7 | ||
|
|
7e7278b99b | ||
|
|
f4d7728f3f | ||
|
|
c9b71496eb | ||
|
|
cd1f9c5a8b | ||
|
|
56379510d0 | ||
|
|
389299ac7d | ||
|
|
9cf04bcb5f | ||
|
|
c370598564 | ||
|
|
04001ff178 | ||
|
|
194c3080ea | ||
|
|
60d1e524ac | ||
|
|
672af1c63d | ||
|
|
6cb728a4ca | ||
|
|
a074650947 | ||
|
|
f169a40c69 | ||
|
|
480d5ee18c | ||
|
|
ba47d797e4 | ||
|
|
3e5f84dcb4 | ||
|
|
e398d98b42 | ||
|
|
09e5f78aae | ||
|
|
ae1680a720 | ||
|
|
9abf557353 | ||
|
|
dc0ec3443e | ||
|
|
d6c6918f85 | ||
|
|
24756442c8 | ||
|
|
c61a0f0712 | ||
|
|
f05bfcb13d | ||
|
|
6d8ea1f2c5 | ||
|
|
f1de0e28ff | ||
|
|
53ce3cf53d | ||
|
|
0ac6d7be87 | ||
|
|
e55401ecd9 | ||
|
|
37a49a14f4 | ||
|
|
7f68b10611 | ||
|
|
a2867ba29d | ||
|
|
0817cf868b | ||
|
|
018ffdaeeb | ||
|
|
eebb9b6a12 | ||
|
|
36f73d40b3 | ||
|
|
db84369000 | ||
|
|
359b2d4e7a | ||
|
|
2af9bd5003 | ||
|
|
a8cbfcbd18 | ||
|
|
dc17d62131 | ||
|
|
f97e22e125 | ||
|
|
1d9ad2ae54 | ||
|
|
c266261d3b | ||
|
|
93c31d4c26 | ||
|
|
c9275db377 | ||
|
|
cf83833d8b | ||
|
|
494f79edb4 | ||
|
|
de3102614a | ||
|
|
a6f0924c05 | ||
|
|
99dc4f6787 | ||
|
|
5f2ad6432e | ||
|
|
f8c34caaab | ||
|
|
8c2399446b | ||
|
|
95c781bf4d | ||
|
|
fe58de0997 | ||
|
|
7582458bae | ||
|
|
3a7d7afaab | ||
|
|
321eeacff0 | ||
|
|
8ae43ff9a0 | ||
|
|
e6efd5e731 | ||
|
|
7c1c8a5486 | ||
|
|
7932562fa6 | ||
|
|
ac22843abc | ||
|
|
eb83386098 | ||
|
|
7877075847 | ||
|
|
7206dd8219 | ||
|
|
f21e1e7641 | ||
|
|
c2a3f5e498 | ||
|
|
63c0db482f | ||
|
|
d2456be3dd | ||
|
|
c3c08482ac | ||
|
|
62126f0c32 | ||
|
|
28139560c2 | ||
|
|
45c916fb6d | ||
|
|
727d4e70ae | ||
|
|
261c5d2be8 |
@@ -1,6 +0,0 @@
|
|||||||
{ fetchgit }:
|
|
||||||
fetchgit {
|
|
||||||
url = "https://git.clan.lol/clan/clan-core.git";
|
|
||||||
rev = "5d884cecc2585a29b6a3596681839d081b4de192";
|
|
||||||
sha256 = "09is1afmncamavb2q88qac37vmsijxzsy1iz1vr6gsyjq2rixaxc";
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
|
|||||||
10
checks/installation/facter-report.nix
Normal file
10
checks/installation/facter-report.nix
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
system:
|
||||||
|
builtins.fetchurl {
|
||||||
|
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${system}.json";
|
||||||
|
sha256 =
|
||||||
|
{
|
||||||
|
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
|
||||||
|
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
|
||||||
|
}
|
||||||
|
.${system};
|
||||||
|
}
|
||||||
@@ -18,27 +18,23 @@
|
|||||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||||
|
|
||||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
imports = [
|
||||||
|
self.nixosModules.test-install-machine-without-system
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
clan.machines.test-install-machine-with-system =
|
clan.machines.test-install-machine-with-system =
|
||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
# https://git.clan.lol/clan/test-fixtures
|
# https://git.clan.lol/clan/test-fixtures
|
||||||
facter.reportPath = builtins.fetchurl {
|
facter.reportPath = import ./facter-report.nix pkgs.hostPlatform.system;
|
||||||
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${pkgs.hostPlatform.system}.json";
|
|
||||||
sha256 =
|
|
||||||
{
|
|
||||||
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
|
|
||||||
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
|
|
||||||
}
|
|
||||||
.${pkgs.hostPlatform.system};
|
|
||||||
};
|
|
||||||
|
|
||||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||||
|
|
||||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||||
};
|
};
|
||||||
|
|
||||||
flake.nixosModules = {
|
flake.nixosModules = {
|
||||||
test-install-machine-without-system =
|
test-install-machine-without-system =
|
||||||
{ lib, modulesPath, ... }:
|
{ lib, modulesPath, ... }:
|
||||||
@@ -159,6 +155,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.bash.drvPath
|
pkgs.bash.drvPath
|
||||||
pkgs.buildPackages.xorg.lndir
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
(import ./facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.stdenvNoCC
|
pkgs.stdenvNoCC
|
||||||
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.bash.drvPath
|
pkgs.bash.drvPath
|
||||||
pkgs.buildPackages.xorg.lndir
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
};
|
};
|
||||||
|
|||||||
32
clanServices/certificates/README.md
Normal file
32
clanServices/certificates/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
This service sets up a certificate authority (CA) that can issue certificates to
|
||||||
|
other machines in your clan. For this the `ca` role is used.
|
||||||
|
It additionally provides a `default` role, that can be applied to all machines
|
||||||
|
in your clan and will make sure they trust your CA.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
The following configuration would add a CA for the top level domain `.foo`. If
|
||||||
|
the machine `server` now hosts a webservice at `https://something.foo`, it will
|
||||||
|
get a certificate from `ca` which is valid inside your clan. The machine
|
||||||
|
`client` will trust this certificate if it makes a request to
|
||||||
|
`https://something.foo`.
|
||||||
|
|
||||||
|
This clan service can be combined with the `coredns` service for easy to deploy,
|
||||||
|
SSL secured clan-internal service hosting.
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inventory = {
|
||||||
|
machines.ca = { };
|
||||||
|
machines.client = { };
|
||||||
|
machines.server = { };
|
||||||
|
|
||||||
|
instances."certificates" = {
|
||||||
|
module.name = "certificates";
|
||||||
|
module.input = "self";
|
||||||
|
|
||||||
|
roles.ca.machines.ca.settings.tlds = [ "foo" ];
|
||||||
|
roles.default.machines.client = { };
|
||||||
|
roles.default.machines.server = { };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
245
clanServices/certificates/default.nix
Normal file
245
clanServices/certificates/default.nix
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
_class = "clan.service";
|
||||||
|
manifest.name = "certificates";
|
||||||
|
manifest.description = "Sets up a certificates internal to your Clan";
|
||||||
|
manifest.categories = [ "Network" ];
|
||||||
|
manifest.readme = builtins.readFile ./README.md;
|
||||||
|
|
||||||
|
roles.ca = {
|
||||||
|
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
|
||||||
|
options.acmeEmail = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "none@none.tld";
|
||||||
|
description = ''
|
||||||
|
Email address for account creation and correspondence from the CA.
|
||||||
|
It is recommended to use the same email for all certs to avoid account
|
||||||
|
creation limits.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.tlds = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
description = "Top level domain for this CA. Certificates will be issued and trusted for *.<tld>";
|
||||||
|
};
|
||||||
|
|
||||||
|
options.expire = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
description = "When the certificate should expire.";
|
||||||
|
default = "8760h";
|
||||||
|
example = "8760h";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
perInstance =
|
||||||
|
{ settings, ... }:
|
||||||
|
{
|
||||||
|
nixosModule =
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
domains = map (tld: "ca.${tld}") settings.tlds;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
security.acme.defaults.email = settings.acmeEmail;
|
||||||
|
security.acme = {
|
||||||
|
certs = builtins.listToAttrs (
|
||||||
|
map (domain: {
|
||||||
|
name = domain;
|
||||||
|
value = {
|
||||||
|
server = "https://${domain}:1443/acme/acme/directory";
|
||||||
|
};
|
||||||
|
}) domains
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = [
|
||||||
|
80
|
||||||
|
443
|
||||||
|
];
|
||||||
|
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
recommendedProxySettings = true;
|
||||||
|
virtualHosts = builtins.listToAttrs (
|
||||||
|
map (domain: {
|
||||||
|
name = domain;
|
||||||
|
value = {
|
||||||
|
addSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/".proxyPass = "https://localhost:1443";
|
||||||
|
locations."= /ca.crt".alias =
|
||||||
|
config.clan.core.vars.generators.step-intermediate-cert.files."intermediate.crt".path;
|
||||||
|
};
|
||||||
|
}) domains
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
clan.core.vars.generators = {
|
||||||
|
|
||||||
|
# Intermediate key generator
|
||||||
|
"step-intermediate-key" = {
|
||||||
|
files."intermediate.key" = {
|
||||||
|
secret = true;
|
||||||
|
deploy = true;
|
||||||
|
owner = "step-ca";
|
||||||
|
group = "step-ca";
|
||||||
|
};
|
||||||
|
runtimeInputs = [ pkgs.step-cli ];
|
||||||
|
script = ''
|
||||||
|
step crypto keypair --kty EC --curve P-256 --no-password --insecure $out/intermediate.pub $out/intermediate.key
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Intermediate certificate generator
|
||||||
|
"step-intermediate-cert" = {
|
||||||
|
files."intermediate.crt".secret = false;
|
||||||
|
dependencies = [
|
||||||
|
"step-ca"
|
||||||
|
"step-intermediate-key"
|
||||||
|
];
|
||||||
|
runtimeInputs = [ pkgs.step-cli ];
|
||||||
|
script = ''
|
||||||
|
# Create intermediate certificate
|
||||||
|
step certificate create \
|
||||||
|
--ca $in/step-ca/ca.crt \
|
||||||
|
--ca-key $in/step-ca/ca.key \
|
||||||
|
--ca-password-file /dev/null \
|
||||||
|
--key $in/step-intermediate-key/intermediate.key \
|
||||||
|
--template ${pkgs.writeText "intermediate.tmpl" ''
|
||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"keyUsage": ["certSign", "crlSign"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": true,
|
||||||
|
"maxPathLen": 0
|
||||||
|
},
|
||||||
|
"nameConstraints": {
|
||||||
|
"critical": true,
|
||||||
|
"permittedDNSDomains": [${
|
||||||
|
(lib.strings.concatStringsSep "," (map (tld: ''"${tld}"'') settings.tlds))
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''} ${lib.optionalString (settings.expire != null) "--not-after ${settings.expire}"} \
|
||||||
|
--not-before=-12h \
|
||||||
|
--no-password --insecure \
|
||||||
|
"Clan Intermediate CA" \
|
||||||
|
$out/intermediate.crt
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.step-ca = {
|
||||||
|
enable = true;
|
||||||
|
intermediatePasswordFile = "/dev/null";
|
||||||
|
address = "0.0.0.0";
|
||||||
|
port = 1443;
|
||||||
|
settings = {
|
||||||
|
root = config.clan.core.vars.generators.step-ca.files."ca.crt".path;
|
||||||
|
crt = config.clan.core.vars.generators.step-intermediate-cert.files."intermediate.crt".path;
|
||||||
|
key = config.clan.core.vars.generators.step-intermediate-key.files."intermediate.key".path;
|
||||||
|
dnsNames = domains;
|
||||||
|
logger.format = "text";
|
||||||
|
db = {
|
||||||
|
type = "badger";
|
||||||
|
dataSource = "/var/lib/step-ca/db";
|
||||||
|
};
|
||||||
|
authority = {
|
||||||
|
provisioners = [
|
||||||
|
{
|
||||||
|
type = "ACME";
|
||||||
|
name = "acme";
|
||||||
|
forceCN = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
claims = {
|
||||||
|
maxTLSCertDuration = "2160h";
|
||||||
|
defaultTLSCertDuration = "2160h";
|
||||||
|
};
|
||||||
|
backdate = "1m0s";
|
||||||
|
};
|
||||||
|
tls = {
|
||||||
|
cipherSuites = [
|
||||||
|
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
|
||||||
|
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
|
||||||
|
];
|
||||||
|
minVersion = 1.2;
|
||||||
|
maxVersion = 1.3;
|
||||||
|
renegotiation = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Empty role, so we can add non-ca machins to the instance to trust the CA
|
||||||
|
roles.default = {
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
options.acmeEmail = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "none@none.tld";
|
||||||
|
description = ''
|
||||||
|
Email address for account creation and correspondence from the CA.
|
||||||
|
It is recommended to use the same email for all certs to avoid account
|
||||||
|
creation limits.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
perInstance =
|
||||||
|
{ settings, ... }:
|
||||||
|
{
|
||||||
|
nixosModule.security.acme.defaults.email = settings.acmeEmail;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# All machines (independent of role) will trust the CA
|
||||||
|
perMachine.nixosModule =
|
||||||
|
{ pkgs, config, ... }:
|
||||||
|
{
|
||||||
|
# Root CA generator
|
||||||
|
clan.core.vars.generators = {
|
||||||
|
"step-ca" = {
|
||||||
|
share = true;
|
||||||
|
files."ca.key" = {
|
||||||
|
secret = true;
|
||||||
|
deploy = false;
|
||||||
|
};
|
||||||
|
files."ca.crt".secret = false;
|
||||||
|
runtimeInputs = [ pkgs.step-cli ];
|
||||||
|
script = ''
|
||||||
|
step certificate create --template ${pkgs.writeText "root.tmpl" ''
|
||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"issuer": {{ toJson .Subject }},
|
||||||
|
"keyUsage": ["certSign", "crlSign"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": true,
|
||||||
|
"maxPathLen": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''} "Clan Root CA" $out/ca.crt $out/ca.key \
|
||||||
|
--kty EC --curve P-256 \
|
||||||
|
--not-after=8760h \
|
||||||
|
--not-before=-12h \
|
||||||
|
--no-password --insecure
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
security.pki.certificateFiles = [ config.clan.core.vars.generators."step-ca".files."ca.crt".path ];
|
||||||
|
environment.systemPackages = [ pkgs.openssl ];
|
||||||
|
security.acme.acceptTerms = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
21
clanServices/certificates/flake-module.nix
Normal file
21
clanServices/certificates/flake-module.nix
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
module = lib.modules.importApply ./default.nix {
|
||||||
|
inherit (self) packages;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.modules.certificates = module;
|
||||||
|
perSystem =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
clan.nixosTests.certificates = {
|
||||||
|
imports = [ ./tests/vm/default.nix ];
|
||||||
|
clan.modules.certificates = module;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
84
clanServices/certificates/tests/vm/default.nix
Normal file
84
clanServices/certificates/tests/vm/default.nix
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
name = "certificates";
|
||||||
|
|
||||||
|
clan = {
|
||||||
|
directory = ./.;
|
||||||
|
inventory = {
|
||||||
|
|
||||||
|
machines.ca = { }; # 192.168.1.1
|
||||||
|
machines.client = { }; # 192.168.1.2
|
||||||
|
machines.server = { }; # 192.168.1.3
|
||||||
|
|
||||||
|
instances."certificates" = {
|
||||||
|
module.name = "certificates";
|
||||||
|
module.input = "self";
|
||||||
|
|
||||||
|
roles.ca.machines.ca.settings.tlds = [ "foo" ];
|
||||||
|
roles.default.machines.client = { };
|
||||||
|
roles.default.machines.server = { };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes =
|
||||||
|
let
|
||||||
|
hostConfig = ''
|
||||||
|
192.168.1.1 ca.foo
|
||||||
|
192.168.1.3 test.foo
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
|
||||||
|
client.networking.extraHosts = hostConfig;
|
||||||
|
ca.networking.extraHosts = hostConfig;
|
||||||
|
|
||||||
|
server = {
|
||||||
|
|
||||||
|
networking.extraHosts = hostConfig;
|
||||||
|
|
||||||
|
# TODO: Could this be set automatically?
|
||||||
|
# I would like to get this information from the coredns module, but we
|
||||||
|
# cannot model dependencies yet
|
||||||
|
security.acme.certs."test.foo".server = "https://ca.foo/acme/acme/directory";
|
||||||
|
|
||||||
|
# Host a simple service on 'server', with SSL provided via our CA. 'client'
|
||||||
|
# should be able to curl it via https and accept the certificates
|
||||||
|
# presented
|
||||||
|
networking.firewall.allowedTCPPorts = [
|
||||||
|
80
|
||||||
|
443
|
||||||
|
];
|
||||||
|
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts."test.foo" = {
|
||||||
|
enableACME = true;
|
||||||
|
forceSSL = true;
|
||||||
|
locations."/" = {
|
||||||
|
return = "200 'test server response'";
|
||||||
|
extraConfig = "add_header Content-Type text/plain;";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
start_all()
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
ca.succeed("systemctl restart acme-order-renew-ca.foo.service ")
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
server.succeed("systemctl restart acme-test.foo.service")
|
||||||
|
|
||||||
|
# It takes a while for the correct certs to appear (before that self-signed
|
||||||
|
# are presented by nginx) so we wait for a bit.
|
||||||
|
client.wait_until_succeeds("curl -v https://test.foo")
|
||||||
|
|
||||||
|
# Show certificate information for debugging
|
||||||
|
client.succeed("openssl s_client -connect test.foo:443 -servername test.foo </dev/null 2>/dev/null | openssl x509 -text -noout 1>&2")
|
||||||
|
'';
|
||||||
|
}
|
||||||
6
clanServices/certificates/tests/vm/sops/machines/ca/key.json
Executable file
6
clanServices/certificates/tests/vm/sops/machines/ca/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1yd2cden7jav8x4nzx2fwze2fsa5j0qm2m3t7zum765z3u4gj433q7dqj43",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
clanServices/certificates/tests/vm/sops/machines/client/key.json
Executable file
6
clanServices/certificates/tests/vm/sops/machines/client/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1js225d8jc507sgcg0fdfv2x3xv3asm4ds5c6s4hp37nq8spxu95sc5x3ce",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
clanServices/certificates/tests/vm/sops/machines/server/key.json
Executable file
6
clanServices/certificates/tests/vm/sops/machines/server/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1nwuh8lc604mnz5r8ku8zswyswnwv02excw237c0cmtlejp7xfp8sdrcwfa",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:6+XilULKRuWtAZ6B8Lj9UqCfi1T6dmqrDqBNXqS4SvBwM1bIWiL6juaT1Q7ByOexzID7tY740gmQBqTey54uLydh8mW0m4ZtUqw=,iv:9kscsrMPBGkutTnxrc5nrc7tQXpzLxw+929pUDKqTu0=,tag:753uIjm8ZRs0xsjiejEY8g==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1d3kycldZRXhmR0FqTXJp\nWWU0MDBYNmxxbFE5M2xKYm5KWnQ0MXBHNEM4CjN4RFFVcFlkd3pjTFVDQ3Vackdj\nVTVhMWoxdFpsWHp5S1p4L05kYk5LUkkKLS0tIENtZFZZTjY2amFVQmZLZFplQzBC\nZm1vWFI4MXR1ZHIxTTQ5VXdSYUhvOTQKte0bKjXQ0xA8FrpuChjDUvjVqp97D8kT\n3tVh6scdjxW48VSBZP1GRmqcMqCdj75GvJTbWeNEV4PDBW7GI0UW+Q==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-09-02T08:42:39Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:AftMorrH7qX5ctVu5evYHn5h9pC4Mmm2VYaAV8Hy0PKTc777jNsL6DrxFVV3NVqtecpwrzZFWKgzukcdcRJe4veVeBrusmoZYtifH0AWZTEVpVlr2UXYYxCDmNZt1WHfVUo40bT//X6QM0ye6a/2Y1jYPbMbryQNcGmnpk9PDvU=,iv:5nk+d8hzA05LQp7ZHRbIgiENg2Ha6J6YzyducM6zcNU=,tag:dy1hqWVzMu/+fSK57h9ZCA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../users/admin
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:jdTuGQUYvT1yXei1RHKsOCsABmMlkcLuziHDVhA7NequZeNu0fSbrJTXQDCHsDGhlYRcjU5EsEDT750xdleXuD3Gs9zWvPVobI4=,iv:YVow3K1j6fzRF9bRfIEpuOkO/nRpku/UQxWNGC+UJQQ=,tag:cNLM5R7uu6QpwPB9K6MYzg==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvOVF2WXRSL0NpQzFZR01I\nNU85TGcyQmVDazN1dmpuRFVTZEg5NDRKTGhrCk1IVjFSU1V6WHBVRnFWcHkyVERr\nTjFKbW1mQ2FWOWhjN2VPamMxVEQ5VkkKLS0tIENVUGlhanhuWGtDKzBzRmk2dE4v\nMXZBRXNMa3IrOTZTNHRUWVE3UXEwSWMK2cBLoL/H/Vxd/klVrqVLdX9Mww5j7gw/\nEWc5/hN+km6XoW+DiJxVG4qaJ7qqld6u5ZnKgJT+2h9CfjA04I2akg==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-09-02T08:42:51Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:zOBQVM2Ydu4v0+Fw3p3cEU+5+7eKaadV0tKro1JVOxclG1Vs6Myq57nw2eWf5JxIl0ulL+FavPKY26qOQ3aqcGOT3PMRlCda9z+0oSn9Im9bE/DzAGmoH/bp76kFkgTTOCZTMUoqJ+UJqv0qy1BH/92sSSKmYshEX6d1vr5ISrw=,iv:i9ZW4sLxOCan4UokHlySVr1CW39nCTusG4DmEPj/gIw=,tag:iZBDPHDkE3Vt5mFcFu1TPQ==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../users/admin
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:5CJuHcxJMXZJ8GqAeG3BrbWtT1kade4kxgJsn1cRpmr1UgN0ZVYnluPEiBscClNSOzcc6vcrBpfTI3dj1tASKTLP58M+GDBFQDo=,iv:gsK7XqBGkYCoqAvyFlIXuJ27PKSbTmy7f6cgTmT2gow=,tag:qG5KejkBvy9ytfhGXa/Mnw==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxbzVqYkplTzJKN1pwS3VM\naFFIK2VsR3lYUVExYW9ieERBL0tlcFZtVzJRCkpiLzdmWmFlOUZ5QUJ4WkhXZ2tQ\nZm92YXBCV0RpYnIydUdEVTRiamI4bjAKLS0tIG93a2htS1hFcjBOeVFnNCtQTHVr\na2FPYjVGbWtORjJVWXE5bndPU1RWcXMKikMEB7X+kb7OtiyqXn3HRpLYkCdoayDh\n7cjGnplk17q25/lRNHM4JVS5isFfuftCl01enESqkvgq+cwuFwa9DQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-09-02T08:42:59Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:xybV2D0xukZnH2OwRpIugPnS7LN9AbgGKwFioPJc1FQWx9TxMUVDwgMN6V5WrhWkXgF2zP4krtDYpEz4Vq+LbOjcnTUteuCc+7pMHubuRuip7j+M32MH1kuf4bVZuXbCfvm7brGxe83FzjoioLqzA8g/X6Q1q7/ErkNeFjluC3Q=,iv:QEW3EUKSRZY3fbXlP7z+SffWkQeXwMAa5K8RQW7NvPE=,tag:DhFxY7xr7H1Wbd527swD0Q==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../users/admin
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.11
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBsDCCAVegAwIBAgIQbT1Ivm+uwyf0HNkJfan2BTAKBggqhkjOPQQDAjAXMRUw
|
||||||
|
EwYDVQQDEwxDbGFuIFJvb3QgQ0EwHhcNMjUwOTAxMjA0MzAzWhcNMjYwOTAyMDg0
|
||||||
|
MzAzWjAfMR0wGwYDVQQDExRDbGFuIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49
|
||||||
|
AgEGCCqGSM49AwEHA0IABDXCNrUIotju9P1U6JxLV43sOxLlRphQJS4dM+lvjTZc
|
||||||
|
aQ+HwQg0AHVlQNRwS3JqKrJJtJVyKbZklh6eFaDPoj6jfTB7MA4GA1UdDwEB/wQE
|
||||||
|
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRKHaccHgP2ccSWVBWN
|
||||||
|
zGoDdTg7aTAfBgNVHSMEGDAWgBSfsnz4phMJx9su/kgeF/FbZQCBgzAVBgNVHR4B
|
||||||
|
Af8ECzAJoAcwBYIDZm9vMAoGCCqGSM49BAMCA0cAMEQCICiUDk1zGNzpS/iVKLfW
|
||||||
|
zUGaCagpn2mCx4xAXQM9UranAiAn68nVYGWjkzhU31wyCAupxOjw7Bt96XXqIAz9
|
||||||
|
hLLtMA==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/machines/ca
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:Auonh9fa7jSkld1Zyxw74x5ydj6Xc+0SOgiqumVETNCfner9K96Rmv1PkREuHNGWPsnzyEM3pRT8ijvu3QoKvy9QPCCewyT07Wqe4G74+bk1iMeAHsV3To6kHs6M8OISvE+CmG0+hlLmdfRSabTzyWPLHbOjvFTEEuA5G7xiryacSYOE++eeEHdn+oUDh/IMTcfLjCGMjsXFikx1Hb+ofeRTlCg47+0w4MXVvQkOzQB5V2C694jZXvZ19jd/ioqr8YASz2xatGvqwW6cpZxqOWyZJ0UAj/6yFk6tZWifqVB3wgU=,iv:ITFCrDkeWl4GWCebVq15ei9QmkOLDwUIYojKZ2TU6JU=,tag:8k4iYbCIusUykY79H86WUQ==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsT25UbjJTQ2tzbnQyUm9p\neWx1UlZIeVpocnBqUCt0YnFlN2FOU25Lb0hNCmdXUUsyalRTbHRRQ0NLSGc1YllV\nUXRwaENhaXU1WmdnVDE0UWprUUUyeDAKLS0tIHV3dHU3aG5JclM0V3FadzN0SU14\ndFptbEJUNXQ4QVlqbkJ1TjAvdDQwSGsKcKPWUjhK7wzIpdIdksMShF2fpLdDTUBS\nZiU7P1T+3psxad9qhapvU0JrAY+9veFaYVEHha2aN/XKs8HqUcTp3A==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1yd2cden7jav8x4nzx2fwze2fsa5j0qm2m3t7zum765z3u4gj433q7dqj43",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZFVteVZwVGVmRE9NT3hG\nNGMyS3FSaXluM1FpeUp6SDVMUEpwYzg5SmdvCkRPU0QyU1JicGNkdlMyQWVkT0k3\nL2YrbDhWeGk4WFhxcUFmTmhZQ0pEQncKLS0tIG85Ui9rKzBJQ2VkMFBUQTMvSTlu\nbm8rZ09Wa24rQkNvTTNtYTZBN3MrZlkK7cjNhlUKZdOrRq/nKUsbUQgNTzX8jO+0\nzADpz6WCMvsJ15xazc10BGh03OtdMWl5tcoWMaZ71HWtI9Gip5DH0w==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-09-02T08:42:42Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:9xlO5Yis8DG/y8GjvP63NltD4xEL7zqdHL2cQE8gAoh/ZamAmK5ZL0ld80mB3eIYEPKZYvmUYI4Lkrge2ZdqyDoubrW+eJ3dxn9+StxA9FzXYwUE0t+bbsNJfOOp/kDojf060qLGsu0kAGKd2ca4WiDccR0Cieky335C7Zzhi/Q=,iv:bWQ4wr0CJHSN+6ipUbkYTDWZJyFQjDKszfpVX9EEUsY=,tag:kADIFgJBEGCvr5fPbbdEDA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/users/admin
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.11
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.11
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBcTCCARigAwIBAgIRAIix99+AE7Y+uyiLGaRHEhUwCgYIKoZIzj0EAwIwFzEV
|
||||||
|
MBMGA1UEAxMMQ2xhbiBSb290IENBMB4XDTI1MDkwMTIwNDI1N1oXDTI2MDkwMjA4
|
||||||
|
NDI1N1owFzEVMBMGA1UEAxMMQ2xhbiBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZI
|
||||||
|
zj0DAQcDQgAEk7nn9kzxI+xkRmNMlxD+7T78UqV3aqus0foJh6uu1CHC+XaebMcw
|
||||||
|
JN95nAe3oYA3yZG6Mnq9nCxsYha4EhzGYqNFMEMwDgYDVR0PAQH/BAQDAgEGMBIG
|
||||||
|
A1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFJ+yfPimEwnH2y7+SB4X8VtlAIGD
|
||||||
|
MAoGCCqGSM49BAMCA0cAMEQCIBId/CcbT5MPFL90xa+XQz+gVTdRwsu6Bg7ehMso
|
||||||
|
Bj0oAiBjSlttd5yeuZGXBm+O0Gl+WdKV60QlrWutNewXFS4UpQ==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:PnEXteU3I7U0OKgE+oR3xjHdLWYTpJjM/jlzxtGU0uP2pUBuQv3LxtEz+cP0ZsafHLNq2iNJ7xpUEE0g4d3M296S56oSocK3fREWBiJFiaC7SAEUiil1l3UCwHn7LzmdEmn8Kq7T+FK89wwqtVWIASLo2gZC/yHE5eEanEATTchGLSNiHJRzZ8n0Ekm8EFUA6czOqA5nPQHaSmeLzu1g80lSSi1ICly6dJksa6DVucwOyVFYFEeq8Dfyc1eyP8L1ee0D7QFYBMduYOXTKPtNnyDmdaQMj7cMMvE7fn04idIiAqw=,iv:nvLmAfFk2GXnnUy+Afr648R60Ou13eu9UKykkiA8Y+4=,tag:lTTAxfG0EDCU6u7xlW6xSQ==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEMjNWUm5NbktQeTRWRjJE\nWWFZc2Rsa3I5aitPSno1WnhORENNcng5OHprCjNUQVhBVHFBcWFjaW5UdmxKTnZw\nQlI4MDk5Wkp0RElCeWgzZ2dFQkF2dkkKLS0tIDVreTkydnJ0RDdHSHlQeVV6bGlP\nTmpJOVBSb2dkVS9TZG5SRmFjdnQ1b3cKQ5XvwH1jD4XPVs5RzOotBDq8kiE6S5k2\nDBv6ugjsM5qV7/oGP9H69aSB4jKPZjEn3yiNw++Oorc8uXd5kSGh7w==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-09-02T08:43:00Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:3jFf66UyZUWEtPdPu809LCS3K/Hc6zbnluystl3eXS+KGI+dCoYmN9hQruRNBRxf6jli2RIlArmmEPBDQVt67gG/qugTdT12krWnYAZ78iocmOnkf44fWxn/pqVnn4JYpjEYRgy8ueGDnUkwvpGWVZpcXw5659YeDQuYOJ2mq0U=,iv:3k7fBPrABdLItQ2Z+Mx8Nx0eIEKo93zG/23K+Q5Hl3I=,tag:aehAObdx//DEjbKlOeM7iQ==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../sops/users/admin
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{ ... }:
|
{ ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
_class = "clan.service";
|
_class = "clan.service";
|
||||||
manifest.name = "coredns";
|
manifest.name = "coredns";
|
||||||
@@ -25,6 +26,12 @@
|
|||||||
# TODO: Set a default
|
# TODO: Set a default
|
||||||
description = "IP for the DNS to listen on";
|
description = "IP for the DNS to listen on";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
options.dnsPort = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 1053;
|
||||||
|
description = "Port of the clan-internal DNS server";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
perInstance =
|
perInstance =
|
||||||
@@ -42,8 +49,8 @@
|
|||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 53 ];
|
networking.firewall.allowedTCPPorts = [ settings.dnsPort ];
|
||||||
networking.firewall.allowedUDPPorts = [ 53 ];
|
networking.firewall.allowedUDPPorts = [ settings.dnsPort ];
|
||||||
|
|
||||||
services.coredns =
|
services.coredns =
|
||||||
let
|
let
|
||||||
@@ -74,16 +81,22 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
enable = true;
|
enable = true;
|
||||||
config = ''
|
config =
|
||||||
. {
|
|
||||||
forward . 1.1.1.1
|
|
||||||
cache 30
|
|
||||||
}
|
|
||||||
|
|
||||||
${settings.tld} {
|
let
|
||||||
file ${zonefile}
|
dnsPort = builtins.toString settings.dnsPort;
|
||||||
}
|
in
|
||||||
'';
|
|
||||||
|
''
|
||||||
|
.:${dnsPort} {
|
||||||
|
forward . 1.1.1.1
|
||||||
|
cache 30
|
||||||
|
}
|
||||||
|
|
||||||
|
${settings.tld}:${dnsPort} {
|
||||||
|
file ${zonefile}
|
||||||
|
}
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -107,10 +120,16 @@
|
|||||||
# TODO: Set a default
|
# TODO: Set a default
|
||||||
description = "IP on which the services will listen";
|
description = "IP on which the services will listen";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
options.dnsPort = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 1053;
|
||||||
|
description = "Port of the clan-internal DNS server";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
perInstance =
|
perInstance =
|
||||||
{ roles, ... }:
|
{ roles, settings, ... }:
|
||||||
{
|
{
|
||||||
nixosModule =
|
nixosModule =
|
||||||
{ lib, ... }:
|
{ lib, ... }:
|
||||||
@@ -147,7 +166,7 @@
|
|||||||
];
|
];
|
||||||
stub-zone = map (m: {
|
stub-zone = map (m: {
|
||||||
name = "${roles.server.machines.${m}.settings.tld}.";
|
name = "${roles.server.machines.${m}.settings.tld}.";
|
||||||
stub-addr = "${roles.server.machines.${m}.settings.ip}";
|
stub-addr = "${roles.server.machines.${m}.settings.ip}@${builtins.toString settings.dnsPort}";
|
||||||
}) (lib.attrNames roles.server.machines);
|
}) (lib.attrNames roles.server.machines);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,18 +95,15 @@
|
|||||||
for m in machines:
|
for m in machines:
|
||||||
m.wait_for_unit("network-online.target")
|
m.wait_for_unit("network-online.target")
|
||||||
|
|
||||||
# import time
|
|
||||||
# time.sleep(2333333)
|
|
||||||
|
|
||||||
# This should work, but is borken in tests i think? Instead we dig directly
|
# This should work, but is borken in tests i think? Instead we dig directly
|
||||||
|
|
||||||
# client.succeed("curl -k -v http://one.foo")
|
# client.succeed("curl -k -v http://one.foo")
|
||||||
# client.succeed("curl -k -v http://two.foo")
|
# client.succeed("curl -k -v http://two.foo")
|
||||||
|
|
||||||
answer = client.succeed("dig @192.168.1.2 one.foo")
|
answer = client.succeed("dig @192.168.1.2 -p 1053 one.foo")
|
||||||
assert "192.168.1.3" in answer, "IP not found"
|
assert "192.168.1.3" in answer, "IP not found"
|
||||||
|
|
||||||
answer = client.succeed("dig @192.168.1.2 two.foo")
|
answer = client.succeed("dig @192.168.1.2 -p 1053 two.foo")
|
||||||
assert "192.168.1.4" in answer, "IP not found"
|
assert "192.168.1.4" in answer, "IP not found"
|
||||||
|
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -56,6 +56,11 @@
|
|||||||
systemd.services.telegraf-json = {
|
systemd.services.telegraf-json = {
|
||||||
enable = true;
|
enable = true;
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "telegraf.service" ];
|
||||||
|
wants = [ "telegraf.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
|
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
12
devFlake/flake.lock
generated
12
devFlake/flake.lock
generated
@@ -84,11 +84,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-dev": {
|
"nixpkgs-dev": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756662818,
|
"lastModified": 1757007868,
|
||||||
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=",
|
"narHash": "sha256-zekS8JUSNEiphLnjWJBFoaX4Kb8GxiiD6FvoKZI+8b0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09",
|
"rev": "36420cc41abb467f89082432cfe139f5fdbdcea3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755555503,
|
"lastModified": 1756738487,
|
||||||
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
|
"narHash": "sha256-8QX7Ab5CcICp7zktL47VQVS+QeaU4YDNAjzty7l7TQE=",
|
||||||
"owner": "NuschtOS",
|
"owner": "NuschtOS",
|
||||||
"repo": "search",
|
"repo": "search",
|
||||||
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
|
"rev": "5feeaeefb571e6ca2700888b944f436f7c05149b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ nav:
|
|||||||
- reference/clanServices/index.md
|
- reference/clanServices/index.md
|
||||||
- reference/clanServices/admin.md
|
- reference/clanServices/admin.md
|
||||||
- reference/clanServices/borgbackup.md
|
- reference/clanServices/borgbackup.md
|
||||||
|
- reference/clanServices/certificates.md
|
||||||
- reference/clanServices/coredns.md
|
- reference/clanServices/coredns.md
|
||||||
- reference/clanServices/data-mesher.md
|
- reference/clanServices/data-mesher.md
|
||||||
- reference/clanServices/dyndns.md
|
- reference/clanServices/dyndns.md
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
|
|||||||
# Direct SSH with fallback support
|
# Direct SSH with fallback support
|
||||||
internet = {
|
internet = {
|
||||||
roles.default.machines.server1 = {
|
roles.default.machines.server1 = {
|
||||||
settings.address = "server1.example.com";
|
settings.host = "server1.example.com";
|
||||||
};
|
};
|
||||||
roles.default.machines.server2 = {
|
roles.default.machines.server2 = {
|
||||||
settings.address = "192.168.1.100";
|
settings.host = "192.168.1.100";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ For machines with public IPs or DNS names, use the `internet` service to configu
|
|||||||
# Priority 1: Try direct connection first
|
# Priority 1: Try direct connection first
|
||||||
internet = {
|
internet = {
|
||||||
roles.default.machines.publicserver = {
|
roles.default.machines.publicserver = {
|
||||||
settings.address = "public.example.com";
|
settings.host = "public.example.com";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
18
flake.lock
generated
18
flake.lock
generated
@@ -31,11 +31,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756115622,
|
"lastModified": 1756733629,
|
||||||
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
|
"narHash": "sha256-dwWGlDhcO5SMIvMSTB4mjQ5Pvo2vtxvpIknhVnSz2I8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
|
"rev": "a5c4f2ab72e3d1ab43e3e65aa421c6f2bd2e12a1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -51,11 +51,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1754487366,
|
"lastModified": 1756770412,
|
||||||
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
|
"narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
|
"rev": "4524271976b625a4a605beefd893f270620fd751",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755825449,
|
"lastModified": 1757015938,
|
||||||
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
|
"narHash": "sha256-1qBXNK/QxEjCqIoA2DxWn5gqM8rVxt+OxKodXu1GLTY=",
|
||||||
"owner": "nix-darwin",
|
"owner": "nix-darwin",
|
||||||
"repo": "nix-darwin",
|
"repo": "nix-darwin",
|
||||||
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
|
"rev": "eaacfa1101b84225491d2ceae9549366d74dc214",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ in
|
|||||||
relativeDir = removePrefix "${self}/" (toString config.clan.directory);
|
relativeDir = removePrefix "${self}/" (toString config.clan.directory);
|
||||||
|
|
||||||
update-vars = hostPkgs.writeShellScriptBin "update-vars" ''
|
update-vars = hostPkgs.writeShellScriptBin "update-vars" ''
|
||||||
|
set -x
|
||||||
|
export PRJ_ROOT=$(git rev-parse --show-toplevel)
|
||||||
${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName}
|
${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -268,8 +268,14 @@ class Machine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def nsenter_command(self, command: str) -> list[str]:
|
def nsenter_command(self, command: str) -> list[str]:
|
||||||
|
nsenter = shutil.which("nsenter")
|
||||||
|
|
||||||
|
if not nsenter:
|
||||||
|
msg = "nsenter command not found"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"nsenter",
|
nsenter,
|
||||||
"--target",
|
"--target",
|
||||||
str(self.container_pid),
|
str(self.container_pid),
|
||||||
"--mount",
|
"--mount",
|
||||||
@@ -326,6 +332,7 @@ class Machine:
|
|||||||
|
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
self.nsenter_command(command),
|
self.nsenter_command(command),
|
||||||
|
env={},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
title="Clan App",
|
title="Clan App",
|
||||||
size=Size(1280, 1024, SizeHint.NONE),
|
size=Size(1280, 1024, SizeHint.NONE),
|
||||||
shared_threads=shared_threads,
|
shared_threads=shared_threads,
|
||||||
|
app_id="org.clan.app",
|
||||||
)
|
)
|
||||||
|
|
||||||
API.overwrite_fn(get_system_file)
|
API.overwrite_fn(get_system_file)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import platform
|
|||||||
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
|
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Native handle kinds
|
||||||
|
WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW = 0
|
||||||
|
WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET = 1
|
||||||
|
WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER = 2
|
||||||
|
|
||||||
|
|
||||||
def _encode_c_string(s: str) -> bytes:
|
def _encode_c_string(s: str) -> bytes:
|
||||||
return s.encode("utf-8")
|
return s.encode("utf-8")
|
||||||
@@ -72,6 +77,10 @@ class _WebviewLibrary:
|
|||||||
self.webview_create.argtypes = [c_int, c_void_p]
|
self.webview_create.argtypes = [c_int, c_void_p]
|
||||||
self.webview_create.restype = c_void_p
|
self.webview_create.restype = c_void_p
|
||||||
|
|
||||||
|
self.webview_create_with_app_id = self.lib.webview_create_with_app_id
|
||||||
|
self.webview_create_with_app_id.argtypes = [c_int, c_void_p, c_char_p]
|
||||||
|
self.webview_create_with_app_id.restype = c_void_p
|
||||||
|
|
||||||
self.webview_destroy = self.lib.webview_destroy
|
self.webview_destroy = self.lib.webview_destroy
|
||||||
self.webview_destroy.argtypes = [c_void_p]
|
self.webview_destroy.argtypes = [c_void_p]
|
||||||
|
|
||||||
@@ -105,6 +114,10 @@ class _WebviewLibrary:
|
|||||||
self.webview_return = self.lib.webview_return
|
self.webview_return = self.lib.webview_return
|
||||||
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
|
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
|
||||||
|
|
||||||
|
self.webview_get_native_handle = self.lib.webview_get_native_handle
|
||||||
|
self.webview_get_native_handle.argtypes = [c_void_p, c_int]
|
||||||
|
self.webview_get_native_handle.restype = c_void_p
|
||||||
|
|
||||||
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
|
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
|
||||||
|
|
||||||
self.CFUNCTYPE = CFUNCTYPE
|
self.CFUNCTYPE = CFUNCTYPE
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import platform
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -11,7 +12,10 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from clan_lib.api import MethodRegistry, message_queue
|
from clan_lib.api import MethodRegistry, message_queue
|
||||||
from clan_lib.api.tasks import WebThread
|
from clan_lib.api.tasks import WebThread
|
||||||
|
|
||||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
from ._webview_ffi import (
|
||||||
|
_encode_c_string,
|
||||||
|
_webview_lib,
|
||||||
|
)
|
||||||
from .webview_bridge import WebviewBridge
|
from .webview_bridge import WebviewBridge
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -32,6 +36,21 @@ class FuncStatus(IntEnum):
|
|||||||
FAILURE = 1
|
FAILURE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class NativeHandleKind(IntEnum):
|
||||||
|
# Top-level window. @c GtkWindow pointer (GTK), @c NSWindow pointer (Cocoa)
|
||||||
|
# or @c HWND (Win32)
|
||||||
|
UI_WINDOW = 0
|
||||||
|
|
||||||
|
# Browser widget. @c GtkWidget pointer (GTK), @c NSView pointer (Cocoa) or
|
||||||
|
# @c HWND (Win32).
|
||||||
|
UI_WIDGET = 1
|
||||||
|
|
||||||
|
# Browser controller. @c WebKitWebView pointer (WebKitGTK), @c WKWebView
|
||||||
|
# pointer (Cocoa/WebKit) or @c ICoreWebView2Controller pointer
|
||||||
|
# (Win32/WebView2).
|
||||||
|
BROWSER_CONTROLLER = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Size:
|
class Size:
|
||||||
width: int
|
width: int
|
||||||
@@ -46,6 +65,7 @@ class Webview:
|
|||||||
size: Size | None = None
|
size: Size | None = None
|
||||||
window: int | None = None
|
window: int | None = None
|
||||||
shared_threads: dict[str, WebThread] | None = None
|
shared_threads: dict[str, WebThread] | None = None
|
||||||
|
app_id: str | None = None
|
||||||
|
|
||||||
# initialized later
|
# initialized later
|
||||||
_bridge: WebviewBridge | None = None
|
_bridge: WebviewBridge | None = None
|
||||||
@@ -56,7 +76,14 @@ class Webview:
|
|||||||
def _create_handle(self) -> None:
|
def _create_handle(self) -> None:
|
||||||
# Initialize the webview handle
|
# Initialize the webview handle
|
||||||
with_debugger = True
|
with_debugger = True
|
||||||
handle = _webview_lib.webview_create(int(with_debugger), self.window)
|
|
||||||
|
# Use webview_create_with_app_id only on Linux if app_id is provided
|
||||||
|
if self.app_id and platform.system() == "Linux":
|
||||||
|
handle = _webview_lib.webview_create_with_app_id(
|
||||||
|
int(with_debugger), self.window, _encode_c_string(self.app_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
handle = _webview_lib.webview_create(int(with_debugger), self.window)
|
||||||
callbacks: dict[str, Callable[..., Any]] = {}
|
callbacks: dict[str, Callable[..., Any]] = {}
|
||||||
|
|
||||||
# Since we can't use object.__setattr__, we'll initialize differently
|
# Since we can't use object.__setattr__, we'll initialize differently
|
||||||
@@ -217,6 +244,21 @@ class Webview:
|
|||||||
self._callbacks[name] = c_callback
|
self._callbacks[name] = c_callback
|
||||||
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
|
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
|
||||||
|
|
||||||
|
def get_native_handle(
|
||||||
|
self, kind: NativeHandleKind = NativeHandleKind.UI_WINDOW
|
||||||
|
) -> int | None:
|
||||||
|
"""Get the native handle (platform-dependent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kind: Handle kind - NativeHandleKind enum value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Native handle as integer, or None if failed
|
||||||
|
|
||||||
|
"""
|
||||||
|
handle = _webview_lib.webview_get_native_handle(self.handle, kind.value)
|
||||||
|
return handle if handle else None
|
||||||
|
|
||||||
def unbind(self, name: str) -> None:
|
def unbind(self, name: str) -> None:
|
||||||
if name in self._callbacks:
|
if name in self._callbacks:
|
||||||
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))
|
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
gobject-introspection,
|
gobject-introspection,
|
||||||
gtk4,
|
gtk4,
|
||||||
lib,
|
lib,
|
||||||
|
stdenv,
|
||||||
|
# macOS-specific dependencies
|
||||||
|
imagemagick,
|
||||||
|
makeWrapper,
|
||||||
|
libicns,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
source =
|
source =
|
||||||
@@ -91,7 +96,12 @@ pythonRuntime.pkgs.buildPythonApplication {
|
|||||||
# gtk4 deps
|
# gtk4 deps
|
||||||
wrapGAppsHook4
|
wrapGAppsHook4
|
||||||
]
|
]
|
||||||
++ runtimeDependencies;
|
++ runtimeDependencies
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
|
imagemagick
|
||||||
|
makeWrapper
|
||||||
|
libicns
|
||||||
|
];
|
||||||
|
|
||||||
# The necessity of setting buildInputs and propagatedBuildInputs to the
|
# The necessity of setting buildInputs and propagatedBuildInputs to the
|
||||||
# same values for your Python package within Nix largely stems from ensuring
|
# same values for your Python package within Nix largely stems from ensuring
|
||||||
@@ -148,16 +158,113 @@ pythonRuntime.pkgs.buildPythonApplication {
|
|||||||
postInstall = ''
|
postInstall = ''
|
||||||
mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
||||||
cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
||||||
mkdir -p $out/share/icons/hicolor
|
|
||||||
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
|
${lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
|
||||||
|
mkdir -p $out/share/icons/hicolor
|
||||||
|
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
|
||||||
|
''}
|
||||||
|
|
||||||
|
${lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
|
set -eu pipefail
|
||||||
|
# Create macOS app bundle structure
|
||||||
|
mkdir -p "$out/Applications/Clan App.app/Contents/"{MacOS,Resources}
|
||||||
|
|
||||||
|
# Create Info.plist
|
||||||
|
cat > "$out/Applications/Clan App.app/Contents/Info.plist" << 'EOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Clan App</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Clan App</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>clan-app.icns</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.clan.app</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Clan App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Clan Protocol</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>clan</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create app icon (convert PNG to ICNS using minimal approach to avoid duplicates)
|
||||||
|
# Create a temporary iconset directory structure
|
||||||
|
mkdir clan-app.iconset
|
||||||
|
|
||||||
|
# Create a minimal iconset with only essential, non-duplicate sizes
|
||||||
|
# Each PNG file should map to a unique ICNS type
|
||||||
|
cp ./clan_app/assets/white-favicons/16x16/apps/clan-app.png clan-app.iconset/icon_16x16.png
|
||||||
|
cp ./clan_app/assets/white-favicons/128x128/apps/clan-app.png clan-app.iconset/icon_128x128.png
|
||||||
|
|
||||||
|
# Use libicns png2icns tool to create proper ICNS file with minimal set
|
||||||
|
png2icns "$out/Applications/Clan App.app/Contents/Resources/clan-app.icns" \
|
||||||
|
clan-app.iconset/icon_16x16.png \
|
||||||
|
clan-app.iconset/icon_128x128.png
|
||||||
|
|
||||||
|
# Create PkgInfo file (standard requirement for macOS apps)
|
||||||
|
echo -n "APPL????" > "$out/Applications/Clan App.app/Contents/PkgInfo"
|
||||||
|
|
||||||
|
# Create the main executable script with proper process name
|
||||||
|
cat > "$out/Applications/Clan App.app/Contents/MacOS/Clan App" << EOF
|
||||||
|
#!/bin/bash
|
||||||
|
# Execute with the correct process name for app icon to appear
|
||||||
|
exec -a "\$0" "$out/bin/.clan-app-orig" "\$@"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "$out/Applications/Clan App.app/Contents/MacOS/Clan App"
|
||||||
|
set +eu pipefail
|
||||||
|
''}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# TODO: If we start clan-app over the cli the process name is "python" and icons don't show up correctly on macOS
|
||||||
|
# I looked in how blender does it, but couldn't figure it out yet.
|
||||||
|
# They do an exec -a in their wrapper script, but that doesn't seem to work here.
|
||||||
|
|
||||||
# Don't leak python packages into a devshell.
|
# Don't leak python packages into a devshell.
|
||||||
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
|
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
|
||||||
postFixup = ''
|
postFixup = ''
|
||||||
rm $out/nix-support/propagated-build-inputs
|
rm $out/nix-support/propagated-build-inputs
|
||||||
|
''
|
||||||
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
|
set -eu pipefail
|
||||||
|
mv $out/bin/clan-app $out/bin/.clan-app-orig
|
||||||
|
|
||||||
|
|
||||||
|
# Create command line wrapper that executes the app bundle
|
||||||
|
cat > $out/bin/clan-app << EOF
|
||||||
|
#!/bin/bash
|
||||||
|
exec "$out/Applications/Clan App.app/Contents/MacOS/Clan App" "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x $out/bin/clan-app
|
||||||
|
set +eu pipefail
|
||||||
'';
|
'';
|
||||||
checkPhase = ''
|
checkPhase = ''
|
||||||
|
set -eu pipefail
|
||||||
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
|
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
|
||||||
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
|
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
|
||||||
|
|
||||||
@@ -171,6 +278,7 @@ pythonRuntime.pkgs.buildPythonApplication {
|
|||||||
fc-list
|
fc-list
|
||||||
|
|
||||||
PYTHONPATH= $out/bin/clan-app --help
|
PYTHONPATH= $out/bin/clan-app --help
|
||||||
|
set +eu pipefail
|
||||||
'';
|
'';
|
||||||
desktopItems = [ desktop-file ];
|
desktopItems = [ desktop-file ];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ let
|
|||||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
||||||
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
|
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
|
||||||
};
|
};
|
||||||
commitMono_ttf = fetchurl {
|
archivoSemi_ttf = fetchurl {
|
||||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
|
url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf";
|
||||||
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
|
hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w=";
|
||||||
};
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
@@ -66,5 +66,5 @@ runCommand "" { } ''
|
|||||||
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
||||||
|
|
||||||
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
||||||
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
|
cp ${archivoSemi_ttf} $out/ArchivoSemiCondensed-Medium.ttf
|
||||||
''
|
''
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
|
||||||
if ! command -v xdg-mime &> /dev/null; then
|
|
||||||
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))')
|
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))')
|
||||||
|
|
||||||
if [ "$ALREADY_INSTALLED" = "true" ]; then
|
if [ "$ALREADY_INSTALLED" = "true" ]; then
|
||||||
@@ -14,9 +9,23 @@ else
|
|||||||
nix profile install .#clan-app
|
nix profile install .#clan-app
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check OS type
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
|
||||||
# install desktop file
|
if ! command -v xdg-mime &> /dev/null; then
|
||||||
set -eou pipefail
|
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
||||||
DESKTOP_FILE_NAME=org.clan.app.desktop
|
fi
|
||||||
|
|
||||||
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
|
# install desktop file on Linux
|
||||||
|
set -eou pipefail
|
||||||
|
DESKTOP_FILE_NAME=org.clan.app.desktop
|
||||||
|
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
|
||||||
|
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
echo "macOS detected."
|
||||||
|
mkdir -p ~/Applications
|
||||||
|
ln -sf ~/.nix-profile/Applications/Clan\ App.app ~/Applications
|
||||||
|
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f ~/Applications/Clan\ App.app
|
||||||
|
else
|
||||||
|
echo "Unsupported OS: $OSTYPE"
|
||||||
|
fi
|
||||||
|
|||||||
9
pkgs/clan-app/macos-remote.sh
Executable file
9
pkgs/clan-app/macos-remote.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eou pipefail
|
||||||
|
|
||||||
|
rsync --exclude result --exclude .direnv --exclude node_modules --delete -r ~/Projects/clan-core/pkgs/clan-app mac-mini-dev:~/clan-core/pkgs
|
||||||
|
|
||||||
|
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && nix build .#clan-app -Lv --show-trace"
|
||||||
|
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && ./install-desktop.sh"
|
||||||
|
|
||||||
@@ -91,6 +91,8 @@ mkShell {
|
|||||||
pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui"
|
pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui"
|
||||||
export NODE_PATH="$(pwd)/node_modules"
|
export NODE_PATH="$(pwd)/node_modules"
|
||||||
export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH"
|
export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH"
|
||||||
|
|
||||||
|
rm -rf .fonts || true
|
||||||
cp -r ${self'.packages.fonts} .fonts
|
cp -r ${self'.packages.fonts} .fonts
|
||||||
chmod -R +w .fonts
|
chmod -R +w .fonts
|
||||||
mkdir -p api
|
mkdir -p api
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export const Menu = (props: {
|
|||||||
"pointer-events": "auto",
|
"pointer-events": "auto",
|
||||||
}}
|
}}
|
||||||
class={styles.list}
|
class={styles.list}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// Prevent default context menu
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
class={styles.item}
|
class={styles.item}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
fieldset {
|
.fieldset {
|
||||||
@apply flex flex-col w-full;
|
@apply flex flex-col w-full;
|
||||||
|
|
||||||
legend {
|
legend {
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ export const Fieldset = (props: FieldsetProps) => {
|
|||||||
: props.children;
|
: props.children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
class={cx({ inverted: props.inverted })}
|
class={cx("fieldset", { inverted: props.inverted })}
|
||||||
disabled={props.disabled || false}
|
aria-disabled={props.disabled || undefined}
|
||||||
>
|
>
|
||||||
{props.legend && (
|
{props.legend && (
|
||||||
<legend>
|
<legend>
|
||||||
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
div.form-field.machine-tags {
|
|
||||||
div.control {
|
|
||||||
@apply flex flex-col size-full gap-2;
|
|
||||||
|
|
||||||
div.selected-options {
|
|
||||||
@apply flex flex-wrap gap-2 size-full min-h-5;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.input-container {
|
|
||||||
@apply relative left-0 top-0;
|
|
||||||
@apply inline-flex justify-between w-full;
|
|
||||||
|
|
||||||
input {
|
|
||||||
@apply w-full px-2 py-1.5 rounded-sm;
|
|
||||||
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
|
|
||||||
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: "Archivo", sans-serif;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
@apply fg-def-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply bg-def-acc-1 outline-def-acc-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:read-only):focus-visible {
|
|
||||||
@apply bg-def-1 outline-def-acc-3;
|
|
||||||
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 0.125rem theme(colors.bg.def.1),
|
|
||||||
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-invalid] {
|
|
||||||
@apply outline-semantic-error-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-disabled] {
|
|
||||||
@apply outline-def-2 fg-def-4 cursor-not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-readonly] {
|
|
||||||
@apply outline-none border-none bg-inherit;
|
|
||||||
@apply p-0 resize-none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > button.trigger {
|
|
||||||
@apply flex items-center justify-center w-8;
|
|
||||||
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
|
|
||||||
|
|
||||||
&[data-disabled] {
|
|
||||||
@apply cursor-not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > span.icon {
|
|
||||||
@apply h-full w-full py-0.5 px-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.horizontal {
|
|
||||||
@apply flex-row gap-2 justify-between;
|
|
||||||
|
|
||||||
div.control {
|
|
||||||
@apply w-1/2 grow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.s {
|
|
||||||
div.control > div.input-container {
|
|
||||||
& > input {
|
|
||||||
@apply px-1.5 py-1;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
|
|
||||||
&[data-readonly] {
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > button.trigger {
|
|
||||||
@apply top-[0.1875rem] h-4 w-5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.inverted {
|
|
||||||
div.control > div.input-container {
|
|
||||||
& > button.trigger {
|
|
||||||
@apply bg-inv-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > input {
|
|
||||||
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
@apply fg-inv-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply bg-inv-acc-2 outline-inv-acc-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:read-only):focus-visible {
|
|
||||||
@apply bg-inv-acc-4;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 0.125rem theme(colors.bg.inv.1),
|
|
||||||
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-invalid] {
|
|
||||||
@apply outline-semantic-error-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-readonly] {
|
|
||||||
@apply outline-none border-none bg-inherit cursor-auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ghost {
|
|
||||||
div.control > div.input-container {
|
|
||||||
& > input {
|
|
||||||
@apply outline-none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply outline-none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.machine-tags-content {
|
|
||||||
@apply rounded-sm bg-def-1 border border-def-2 z-10;
|
|
||||||
|
|
||||||
transform-origin: var(--kb-combobox-content-transform-origin);
|
|
||||||
animation: machineTagsContentHide 250ms ease-in forwards;
|
|
||||||
|
|
||||||
&[data-expanded] {
|
|
||||||
animation: machineTagsContentShow 250ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul.listbox {
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 360px;
|
|
||||||
|
|
||||||
@apply px-2 py-3;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.item {
|
|
||||||
@apply flex items-center justify-between;
|
|
||||||
@apply relative px-2 py-1;
|
|
||||||
@apply select-none outline-none rounded-[0.25rem];
|
|
||||||
|
|
||||||
color: hsl(240 4% 16%);
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
&[data-disabled] {
|
|
||||||
color: hsl(240 5% 65%);
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-highlighted] {
|
|
||||||
@apply outline-none bg-def-4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-indicator {
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div.machine-tags-control {
|
|
||||||
@apply flex flex-col w-full gap-2;
|
|
||||||
|
|
||||||
& > div.selected-options {
|
|
||||||
@apply flex gap-2 flex-wrap w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div.input-container {
|
|
||||||
@apply w-full flex gap-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes machineTagsContentShow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes machineTagsContentHide {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
207
pkgs/clan-app/ui/src/components/Form/MachineTags.module.css
Normal file
207
pkgs/clan-app/ui/src/components/Form/MachineTags.module.css
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
.machineTags {
|
||||||
|
&.horizontal {
|
||||||
|
@apply flex-row gap-2 justify-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
@apply flex flex-col size-full gap-2;
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
@apply w-1/2 grow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedOptions {
|
||||||
|
@apply flex flex-wrap gap-2 size-full min-h-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
@apply w-full relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply absolute left-1.5;
|
||||||
|
top: calc(50% - 0.5rem);
|
||||||
|
|
||||||
|
&.iconSmall {
|
||||||
|
@apply left-[0.3125rem] size-[0.75rem];
|
||||||
|
top: calc(50% - 0.3125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1 w-full;
|
||||||
|
@apply px-[1.625rem] py-1.5 rounded-sm;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: "Archivo", sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-def-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-acc-1 outline-def-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:read-only):focus-visible {
|
||||||
|
@apply bg-def-1 outline-def-acc-3;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.def.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply outline-def-2 fg-def-4 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-readonly] {
|
||||||
|
@apply outline-none border-none bg-inherit;
|
||||||
|
@apply p-0 resize-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inputSmall {
|
||||||
|
@apply px-[1.25rem] py-1;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
|
||||||
|
&[data-readonly] {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inputInverted {
|
||||||
|
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-inv-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-inv-acc-2 outline-inv-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:read-only):focus-visible {
|
||||||
|
@apply bg-inv-acc-4;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.inv.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-readonly] {
|
||||||
|
@apply outline-none border-none bg-inherit cursor-auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inputGhost {
|
||||||
|
@apply outline-none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comboboxContent {
|
||||||
|
@apply rounded-sm bg-def-1 border border-def-2 z-20;
|
||||||
|
|
||||||
|
transform-origin: var(--kb-combobox-content-transform-origin);
|
||||||
|
animation: machineTagsContentHide 250ms ease-in forwards;
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
animation: machineTagsContentShow 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listbox {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
|
||||||
|
@apply px-2 py-3;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listboxItem {
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
@apply relative px-2 py-1;
|
||||||
|
@apply select-none outline-none rounded-[0.25rem];
|
||||||
|
|
||||||
|
color: hsl(240 4% 16%);
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
color: hsl(240 5% 65%);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlighted] {
|
||||||
|
@apply outline-none bg-def-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.listboxItemInverted {
|
||||||
|
&[data-highlighted] {
|
||||||
|
@apply bg-inv-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemIndicator {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.comboboxContentInverted {
|
||||||
|
@apply bg-inv-1 border-inv-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineTagsControl {
|
||||||
|
@apply flex flex-col w-full gap-2;
|
||||||
|
|
||||||
|
/*& > div.selected-options {*/
|
||||||
|
/* @apply flex gap-2 flex-wrap w-full;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
& > div.input-container {
|
||||||
|
@apply w-full flex gap-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes machineTagsContentShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes machineTagsContentHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
pkgs/clan-app/ui/src/components/Form/MachineTags.stories.tsx
Normal file
50
pkgs/clan-app/ui/src/components/Form/MachineTags.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { MachineTags, MachineTagsProps } from "./MachineTags";
|
||||||
|
import { createForm, setValue } from "@modular-forms/solid";
|
||||||
|
import { Button } from "../Button/Button";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/MachineTags",
|
||||||
|
component: MachineTags,
|
||||||
|
} satisfies Meta<MachineTagsProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [formStore, { Field, Form }] = createForm<{ tags: string[] }>({
|
||||||
|
initialValues: { tags: ["nixos"] },
|
||||||
|
});
|
||||||
|
const handleSubmit = (values: { tags: string[] }) => {
|
||||||
|
console.log("submitting", values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readonly = ["nixos"];
|
||||||
|
const options = ["foo"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Field name="tags" type="string[]">
|
||||||
|
{(field, props) => (
|
||||||
|
<MachineTags
|
||||||
|
onChange={(newVal) => {
|
||||||
|
// Workaround for now, until we manage to use native events
|
||||||
|
setValue(formStore, field.name, newVal);
|
||||||
|
}}
|
||||||
|
name="Tags"
|
||||||
|
defaultOptions={options}
|
||||||
|
readonlyOptions={readonly}
|
||||||
|
readOnly={false}
|
||||||
|
defaultValue={field.value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" hierarchy="primary">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import { FieldProps } from "./Field";
|
import { FieldProps } from "./Field";
|
||||||
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
|
import {
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
splitProps,
|
||||||
|
} from "solid-js";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { Tag } from "@/src/components/Tag/Tag";
|
import { Tag } from "@/src/components/Tag/Tag";
|
||||||
|
|
||||||
import "./MachineTags.css";
|
|
||||||
import { Label } from "@/src/components/Form/Label";
|
import { Label } from "@/src/components/Form/Label";
|
||||||
import { Orienter } from "@/src/components/Form/Orienter";
|
import { Orienter } from "@/src/components/Form/Orienter";
|
||||||
import { CollectionNode } from "@kobalte/core";
|
import { CollectionNode } from "@kobalte/core";
|
||||||
|
import styles from "./MachineTags.module.css";
|
||||||
|
|
||||||
export interface MachineTag {
|
export interface MachineTag {
|
||||||
value: string;
|
value: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
new?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MachineTagsProps = FieldProps & {
|
export type MachineTagsProps = FieldProps & {
|
||||||
name: string;
|
name: string;
|
||||||
input: ComponentProps<"select">;
|
onChange: (values: string[]) => void;
|
||||||
|
defaultValue?: string[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
defaultValue?: string[];
|
|
||||||
defaultOptions?: string[];
|
defaultOptions?: string[];
|
||||||
readonlyOptions?: string[];
|
readonlyOptions?: string[];
|
||||||
};
|
};
|
||||||
@@ -44,26 +50,12 @@ const sortedOptions = (options: MachineTag[]) =>
|
|||||||
const sortedAndUniqueOptions = (options: MachineTag[]) =>
|
const sortedAndUniqueOptions = (options: MachineTag[]) =>
|
||||||
sortedOptions(uniqueOptions(options));
|
sortedOptions(uniqueOptions(options));
|
||||||
|
|
||||||
// customises how each option is displayed in the dropdown
|
|
||||||
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
|
|
||||||
return (
|
|
||||||
<Combobox.Item item={props.item} class="item">
|
|
||||||
<Combobox.ItemLabel>
|
|
||||||
<Typography hierarchy="body" size="xs" weight="bold">
|
|
||||||
{props.item.textValue}
|
|
||||||
</Typography>
|
|
||||||
</Combobox.ItemLabel>
|
|
||||||
<Combobox.ItemIndicator class="item-indicator">
|
|
||||||
<Icon icon="Checkmark" />
|
|
||||||
</Combobox.ItemIndicator>
|
|
||||||
</Combobox.Item>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MachineTags = (props: MachineTagsProps) => {
|
export const MachineTags = (props: MachineTagsProps) => {
|
||||||
// convert default value string[] into MachineTag[]
|
const [local, rest] = splitProps(props, ["defaultValue"]);
|
||||||
|
|
||||||
|
// // convert default value string[] into MachineTag[]
|
||||||
const defaultValue = sortedAndUniqueOptions(
|
const defaultValue = sortedAndUniqueOptions(
|
||||||
(props.defaultValue || []).map((value) => ({ value })),
|
(local.defaultValue || []).map((value) => ({ value })),
|
||||||
);
|
);
|
||||||
|
|
||||||
// convert default options string[] into MachineTag[]
|
// convert default options string[] into MachineTag[]
|
||||||
@@ -77,6 +69,51 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [selectedOptions, setSelectedOptions] =
|
||||||
|
createSignal<MachineTag[]>(defaultValue);
|
||||||
|
|
||||||
|
const handleToggle = (item: CollectionNode<MachineTag>) => () => {
|
||||||
|
setSelectedOptions((current) => {
|
||||||
|
const exists = current.find(
|
||||||
|
(option) => option.value === item.rawValue.value,
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
return current.filter((option) => option.value !== item.rawValue.value);
|
||||||
|
}
|
||||||
|
return [...current, item.rawValue];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// customises how each option is displayed in the dropdown
|
||||||
|
const ItemComponent =
|
||||||
|
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
|
||||||
|
return (
|
||||||
|
<Combobox.Item
|
||||||
|
item={props.item}
|
||||||
|
class={cx(styles.listboxItem, {
|
||||||
|
[styles.listboxItemInverted]: inverted,
|
||||||
|
})}
|
||||||
|
onClick={handleToggle(props.item)}
|
||||||
|
>
|
||||||
|
<Combobox.ItemLabel>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
inverted={inverted}
|
||||||
|
>
|
||||||
|
{props.item.textValue}
|
||||||
|
</Typography>
|
||||||
|
</Combobox.ItemLabel>
|
||||||
|
<Combobox.ItemIndicator class={styles.itemIndicator}>
|
||||||
|
<Icon icon="Checkmark" inverted={inverted} />
|
||||||
|
</Combobox.ItemIndicator>
|
||||||
|
</Combobox.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let selectRef: HTMLSelectElement;
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
// react when enter is pressed inside of the text input
|
// react when enter is pressed inside of the text input
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -85,22 +122,49 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
|
|
||||||
// get the current input value, exiting early if it's empty
|
// get the current input value, exiting early if it's empty
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
if (input.value === "") return;
|
const trimmed = input.value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
setAvailableOptions((options) => {
|
setAvailableOptions((curr) => {
|
||||||
return options.map((option) => {
|
if (curr.find((option) => option.value === trimmed)) {
|
||||||
return {
|
return curr;
|
||||||
...option,
|
}
|
||||||
new: undefined,
|
return [
|
||||||
};
|
...curr,
|
||||||
});
|
{
|
||||||
|
value: trimmed,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
setSelectedOptions((curr) => {
|
||||||
|
if (curr.find((option) => option.value === trimmed)) {
|
||||||
|
return curr;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...curr,
|
||||||
|
{
|
||||||
|
value: trimmed,
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// reset the input value
|
selectRef.dispatchEvent(
|
||||||
|
new Event("input", { bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
selectRef.dispatchEvent(
|
||||||
|
new Event("change", { bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notify when selected options change
|
||||||
|
createEffect(
|
||||||
|
on(selectedOptions, (options) => {
|
||||||
|
props.onChange(options.map((o) => o.value));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const align = () => {
|
const align = () => {
|
||||||
if (props.readOnly) {
|
if (props.readOnly) {
|
||||||
return "center";
|
return "center";
|
||||||
@@ -112,41 +176,19 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Combobox<MachineTag>
|
<Combobox<MachineTag>
|
||||||
multiple
|
multiple
|
||||||
class={cx("form-field", "machine-tags", props.size, props.orientation, {
|
class={cx("form-field", styles.machineTags, props.orientation)}
|
||||||
inverted: props.inverted,
|
|
||||||
ghost: props.ghost,
|
|
||||||
})}
|
|
||||||
{...splitProps(props, ["defaultValue"])[1]}
|
{...splitProps(props, ["defaultValue"])[1]}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
value={selectedOptions()}
|
||||||
options={availableOptions()}
|
options={availableOptions()}
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
optionTextValue="value"
|
optionTextValue="value"
|
||||||
optionLabel="value"
|
optionLabel="value"
|
||||||
optionDisabled="disabled"
|
optionDisabled="disabled"
|
||||||
itemComponent={ItemComponent}
|
itemComponent={ItemComponent(props.inverted || false)}
|
||||||
placeholder="Enter a tag name"
|
placeholder="Start typing a name and press enter"
|
||||||
// triggerMode="focus"
|
onChange={() => {
|
||||||
removeOnBackspace={false}
|
// noop, we handle this via the selectedOptions signal
|
||||||
defaultFilter={() => true}
|
|
||||||
onInput={(event) => {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
|
|
||||||
// as the user types in the input box, we maintain a "new" option
|
|
||||||
// in the list of available options
|
|
||||||
setAvailableOptions((options) => {
|
|
||||||
return [
|
|
||||||
// remove the old "new" entry
|
|
||||||
...options.filter((option) => !option.new),
|
|
||||||
// add the updated "new" entry
|
|
||||||
{ value: input.value, new: true },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
// clear the in-progress "new" option from the list of available options
|
|
||||||
setAvailableOptions((options) => {
|
|
||||||
return options.filter((option) => !option.new);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Orienter orientation={props.orientation} align={align()}>
|
<Orienter orientation={props.orientation} align={align()}>
|
||||||
@@ -156,11 +198,18 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Combobox.HiddenSelect {...props.input} multiple />
|
<Combobox.HiddenSelect
|
||||||
|
multiple
|
||||||
|
ref={(el) => {
|
||||||
|
selectRef = el;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Combobox.Control<MachineTag> class="control">
|
<Combobox.Control<MachineTag>
|
||||||
|
class={cx(styles.control, props.orientation)}
|
||||||
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<div class="selected-options">
|
<div class={styles.selectedOptions}>
|
||||||
<For each={state.selectedOptions()}>
|
<For each={state.selectedOptions()}>
|
||||||
{(option) => (
|
{(option) => (
|
||||||
<Tag
|
<Tag
|
||||||
@@ -177,7 +226,13 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
icon={"Close"}
|
icon={"Close"}
|
||||||
size="0.5rem"
|
size="0.5rem"
|
||||||
inverted={inverted}
|
inverted={inverted}
|
||||||
onClick={() => state.remove(option)}
|
onClick={() =>
|
||||||
|
setSelectedOptions((curr) => {
|
||||||
|
return curr.filter(
|
||||||
|
(o) => o.value !== option.value,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,27 +242,36 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={!props.readOnly}>
|
<Show when={!props.readOnly}>
|
||||||
<div class="input-container">
|
<Combobox.Trigger class={styles.trigger}>
|
||||||
<Combobox.Input onKeyDown={onKeyDown} />
|
<Icon
|
||||||
<Combobox.Trigger class="trigger">
|
icon="Tag"
|
||||||
<Combobox.Icon class="icon">
|
color="secondary"
|
||||||
<Icon
|
inverted={props.inverted}
|
||||||
icon="Expand"
|
class={cx(styles.icon, {
|
||||||
inverted={!props.inverted}
|
[styles.iconSmall]: props.size == "s",
|
||||||
size="100%"
|
})}
|
||||||
/>
|
/>
|
||||||
</Combobox.Icon>
|
<Combobox.Input
|
||||||
</Combobox.Trigger>
|
onKeyDown={onKeyDown}
|
||||||
</div>
|
class={cx(styles.input, {
|
||||||
|
[styles.inputSmall]: props.size == "s",
|
||||||
|
[styles.inputGhost]: props.ghost,
|
||||||
|
[styles.inputInverted]: props.inverted,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Combobox.Trigger>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Combobox.Control>
|
</Combobox.Control>
|
||||||
</Orienter>
|
</Orienter>
|
||||||
|
|
||||||
<Combobox.Portal>
|
<Combobox.Portal>
|
||||||
<Combobox.Content class="machine-tags-content">
|
<Combobox.Content
|
||||||
<Combobox.Listbox class="listbox" />
|
class={cx(styles.comboboxContent, {
|
||||||
|
[styles.comboboxContentInverted]: props.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Combobox.Listbox class={styles.listbox} />
|
||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ div.form-field {
|
|||||||
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||||
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
|
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .start-component {
|
||||||
|
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .end-component {
|
||||||
|
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .start-component,
|
||||||
|
& > .end-component {
|
||||||
|
@apply size-fit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.s {
|
&.s {
|
||||||
@@ -101,7 +114,7 @@ div.form-field {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > .icon {
|
& > .icon {
|
||||||
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
|
@apply w-[0.6875rem] h-[0.6875rem];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
|
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
|
||||||
|
import Icon from "../Icon/Icon";
|
||||||
|
import { Button } from "@kobalte/core/button";
|
||||||
|
|
||||||
const Examples = (props: TextInputProps) => (
|
const Examples = (props: TextInputProps) => (
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
@@ -83,16 +85,38 @@ export const Tooltip: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Icon: Story = {
|
export const WithIcon: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Tooltip.args,
|
...Tooltip.args,
|
||||||
icon: "Checkmark",
|
startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithStartComponent: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
startComponent: (props: { inverted?: boolean }) => (
|
||||||
|
<Button>
|
||||||
|
<Icon icon="EyeClose" color="quaternary" {...props} />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithEndComponent: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
endComponent: (props: { inverted?: boolean }) => (
|
||||||
|
<Button>
|
||||||
|
<Icon icon="EyeOpen" color="quaternary" {...props} />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Ghost: Story = {
|
export const Ghost: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Icon.args,
|
...WithIcon.args,
|
||||||
ghost: true,
|
ghost: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -106,14 +130,14 @@ export const Invalid: Story = {
|
|||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Icon.args,
|
...WithIcon.args,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReadOnly: Story = {
|
export const ReadOnly: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Icon.args,
|
...WithIcon.args,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
defaultValue: "14/05/02",
|
defaultValue: "14/05/02",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,12 +11,20 @@ import "./TextInput.css";
|
|||||||
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||||
import { FieldProps } from "./Field";
|
import { FieldProps } from "./Field";
|
||||||
import { Orienter } from "./Orienter";
|
import { Orienter } from "./Orienter";
|
||||||
import { splitProps } from "solid-js";
|
import {
|
||||||
|
Component,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
onMount,
|
||||||
|
splitProps,
|
||||||
|
} from "solid-js";
|
||||||
|
|
||||||
export type TextInputProps = FieldProps &
|
export type TextInputProps = FieldProps &
|
||||||
TextFieldRootProps & {
|
TextFieldRootProps & {
|
||||||
icon?: IconVariant;
|
icon?: IconVariant;
|
||||||
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||||
|
startComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||||
|
endComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TextInput = (props: TextInputProps) => {
|
export const TextInput = (props: TextInputProps) => {
|
||||||
@@ -28,6 +36,39 @@ export const TextInput = (props: TextInputProps) => {
|
|||||||
"ghost",
|
"ghost",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let inputRef: HTMLInputElement | undefined;
|
||||||
|
let startComponentRef: HTMLDivElement | undefined;
|
||||||
|
let endComponentRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const [startComponentSize, setStartComponentSize] = createSignal({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
const [endComponentSize, setEndComponentSize] = createSignal({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (startComponentRef) {
|
||||||
|
const rect = startComponentRef.getBoundingClientRect();
|
||||||
|
setStartComponentSize({ width: rect.width, height: rect.height });
|
||||||
|
}
|
||||||
|
if (endComponentRef) {
|
||||||
|
const rect = endComponentRef.getBoundingClientRect();
|
||||||
|
setEndComponentSize({ width: rect.width, height: rect.height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (inputRef) {
|
||||||
|
const padding = props.size == "s" ? 6 : 8;
|
||||||
|
|
||||||
|
inputRef.style.paddingLeft = `${startComponentSize().width + padding * 2}px`;
|
||||||
|
inputRef.style.paddingRight = `${endComponentSize().width + padding * 2}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
class={cx(
|
class={cx(
|
||||||
@@ -50,6 +91,11 @@ export const TextInput = (props: TextInputProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
|
{props.startComponent && !props.readOnly && (
|
||||||
|
<div ref={startComponentRef} class="start-component">
|
||||||
|
{props.startComponent({ inverted: props.inverted })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{props.icon && !props.readOnly && (
|
{props.icon && !props.readOnly && (
|
||||||
<Icon
|
<Icon
|
||||||
icon={props.icon}
|
icon={props.icon}
|
||||||
@@ -58,9 +104,17 @@ export const TextInput = (props: TextInputProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
|
ref={inputRef}
|
||||||
{...props.input}
|
{...props.input}
|
||||||
classList={{ "has-icon": props.icon && !props.readOnly }}
|
class={cx({
|
||||||
|
"has-icon": props.icon && !props.readOnly,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
{props.endComponent && !props.readOnly && (
|
||||||
|
<div ref={endComponentRef} class="end-component">
|
||||||
|
{props.endComponent({ inverted: props.inverted })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Orienter>
|
</Orienter>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.loader {
|
.loader {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
@apply w-4 h-4;
|
@apply size-full;
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
& > div.wrapper > div.parent,
|
& > div.wrapper > div.parent,
|
||||||
@@ -15,6 +15,18 @@
|
|||||||
background: #0051ff;
|
background: #0051ff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.sizeDefault {
|
||||||
|
@apply size-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sizeLarge {
|
||||||
|
@apply size-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sizeExtraLarge {
|
||||||
|
@apply size-12;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||||
import { Loader, LoaderProps } from "@/src/components/Loader/Loader";
|
import { Loader, LoaderProps } from "@/src/components/Loader/Loader";
|
||||||
|
|
||||||
|
const LoaderExamples = (props: LoaderProps) => (
|
||||||
|
<div class="grid grid-cols-8">
|
||||||
|
<Loader {...props} size="default" />
|
||||||
|
<Loader {...props} size="l" />
|
||||||
|
<Loader {...props} size="xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const meta: Meta<LoaderProps> = {
|
const meta: Meta<LoaderProps> = {
|
||||||
title: "Components/Loader",
|
title: "Components/Loader",
|
||||||
component: Loader,
|
component: LoaderExamples,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ export type Hierarchy = "primary" | "secondary";
|
|||||||
export interface LoaderProps {
|
export interface LoaderProps {
|
||||||
hierarchy?: Hierarchy;
|
hierarchy?: Hierarchy;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
size?: "default" | "l" | "xl";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Loader = (props: LoaderProps) => {
|
export const Loader = (props: LoaderProps) => {
|
||||||
|
const size = () => props.size || "default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
styles.loader,
|
styles.loader,
|
||||||
styles[props.hierarchy || "primary"],
|
styles[props.hierarchy || "primary"],
|
||||||
props.class,
|
props.class,
|
||||||
|
{
|
||||||
|
[styles.sizeDefault]: size() === "default",
|
||||||
|
[styles.sizeLarge]: size() === "l",
|
||||||
|
[styles.sizeExtraLarge]: size() === "xl",
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class={styles.wrapper}>
|
<div class={styles.wrapper}>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
|||||||
// we will use css transform in the typography component to capitalize
|
// we will use css transform in the typography component to capitalize
|
||||||
const statusText = () => props.status?.replaceAll("_", " ");
|
const statusText = () => props.status?.replaceAll("_", " ");
|
||||||
|
|
||||||
|
// our implementation of machine status in the backend needs more time to bake, so for now we only display if a
|
||||||
|
// machine is not installed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={!status()}>
|
<Match when={!status()}>
|
||||||
@@ -28,9 +31,6 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
|||||||
<Match when={status()}>
|
<Match when={status()}>
|
||||||
<Badge
|
<Badge
|
||||||
class={cx("machine-status", {
|
class={cx("machine-status", {
|
||||||
online: status() == "online",
|
|
||||||
offline: status() == "offline",
|
|
||||||
"out-of-sync": status() == "out_of_sync",
|
|
||||||
"not-installed": status() == "not_installed",
|
"not-installed": status() == "not_installed",
|
||||||
})}
|
})}
|
||||||
textValue={status()}
|
textValue={status()}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
@apply w-60 border-none z-10 h-full flex flex-col;
|
@apply w-60 border-none z-10 h-full flex flex-col rounded-b-md overflow-hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
div.sidebar-body {
|
div.sidebar-body {
|
||||||
@apply py-4 px-2 h-full;
|
@apply py-4 px-2;
|
||||||
|
/* full - (y padding) */
|
||||||
|
height: calc(100% - 2rem);
|
||||||
|
|
||||||
@apply border border-inv-3 rounded-bl-md rounded-br-md;
|
@apply border border-inv-3 rounded-bl-md rounded-br-md;
|
||||||
|
|
||||||
|
/* TODO: This is weird, we shouldn't disable native browser features, a11y impacts incomming */
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
background:
|
background:
|
||||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
|
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -20,13 +21,14 @@ div.sidebar-body {
|
|||||||
@apply backdrop-blur-sm;
|
@apply backdrop-blur-sm;
|
||||||
|
|
||||||
.accordion {
|
.accordion {
|
||||||
@apply w-full mb-4;
|
@apply w-full mb-4 h-full flex flex-col justify-start gap-4;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@apply mb-0;
|
@apply mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .item {
|
& > .item {
|
||||||
|
max-height: 50%;
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@apply mb-0;
|
@apply mb-0;
|
||||||
}
|
}
|
||||||
@@ -58,9 +60,13 @@ div.sidebar-body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > .content {
|
& > .content {
|
||||||
@apply overflow-hidden flex flex-col;
|
@apply flex flex-col;
|
||||||
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
|
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
|
||||||
|
|
||||||
|
max-height: calc(100% - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||||
|
|
||||||
&[data-expanded] {
|
&[data-expanded] {
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { A } from "@solidjs/router";
|
|||||||
import { Accordion } from "@kobalte/core/accordion";
|
import { Accordion } from "@kobalte/core/accordion";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { For, Show, useContext } from "solid-js";
|
import { For, Show } from "solid-js";
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
import { buildMachinePath, buildServicePath } from "@/src/hooks/clan";
|
||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import { SidebarProps } from "./Sidebar";
|
import { SidebarProps } from "./Sidebar";
|
||||||
import { Button } from "../Button/Button";
|
import { Button } from "../Button/Button";
|
||||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
import { Instance } from "@/src/workflows/Service/models";
|
||||||
|
|
||||||
interface MachineProps {
|
interface MachineProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -33,19 +34,19 @@ const MachineRoute = (props: MachineProps) => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
inverted={true}
|
inverted
|
||||||
>
|
>
|
||||||
{props.name}
|
{props.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<MachineStatus status={status()} />
|
<MachineStatus status={status()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-row items-center gap-1">
|
<div class="flex w-full flex-row items-center gap-1">
|
||||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
<Icon icon="Flash" size="0.75rem" inverted color="tertiary" />
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
family="mono"
|
family="mono"
|
||||||
size="s"
|
size="s"
|
||||||
inverted={true}
|
inverted
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{props.serviceCount}
|
{props.serviceCount}
|
||||||
@@ -56,18 +57,13 @@ const MachineRoute = (props: MachineProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SidebarBody = (props: SidebarProps) => {
|
const Machines = () => {
|
||||||
const clanURI = useClanURI();
|
|
||||||
|
|
||||||
const ctx = useClanContext();
|
const ctx = useClanContext();
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("ClanContext not found");
|
||||||
|
}
|
||||||
|
|
||||||
const sectionLabels = (props.staticSections || []).map(
|
const clanURI = ctx.clanURI;
|
||||||
(section) => section.title,
|
|
||||||
);
|
|
||||||
|
|
||||||
// controls which sections are open by default
|
|
||||||
// we want them all to be open by default
|
|
||||||
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
|
||||||
|
|
||||||
const machines = () => {
|
const machines = () => {
|
||||||
if (!ctx.machinesQuery.isSuccess) {
|
if (!ctx.machinesQuery.isSuccess) {
|
||||||
@@ -78,6 +74,173 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
return Object.keys(result).length > 0 ? result : undefined;
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item class="item" value="machines">
|
||||||
|
<Accordion.Header class="header">
|
||||||
|
<Accordion.Trigger class="trigger">
|
||||||
|
<Typography
|
||||||
|
class="section-title"
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="xs"
|
||||||
|
inverted
|
||||||
|
color="tertiary"
|
||||||
|
>
|
||||||
|
Your Machines
|
||||||
|
</Typography>
|
||||||
|
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content class="content">
|
||||||
|
<Show
|
||||||
|
when={machines()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
||||||
|
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||||
|
No machines yet
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
size="s"
|
||||||
|
startIcon="Machine"
|
||||||
|
onClick={() => ctx.setShowAddMachine(true)}
|
||||||
|
>
|
||||||
|
Add machine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav>
|
||||||
|
<For each={Object.entries(machines()!)}>
|
||||||
|
{([id, machine]) => (
|
||||||
|
<MachineRoute
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineID={id}
|
||||||
|
name={machine.data.name || id}
|
||||||
|
serviceCount={machine?.instance_refs?.length ?? 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</Show>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServiceRoute = (props: {
|
||||||
|
clanURI: string;
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
instance: Instance;
|
||||||
|
}) => (
|
||||||
|
<A
|
||||||
|
href={buildServicePath({
|
||||||
|
clanURI: props.clanURI,
|
||||||
|
id: props.id,
|
||||||
|
module: props.instance.module,
|
||||||
|
})}
|
||||||
|
replace={true}
|
||||||
|
>
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="flex flex-row items-center justify-between">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
color="primary"
|
||||||
|
inverted
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</Typography>
|
||||||
|
<Icon icon="Code" size="0.75rem" inverted color="tertiary" />
|
||||||
|
</div>
|
||||||
|
{/* Same subtitle as Machine */}
|
||||||
|
{/* <div class="flex w-full flex-row items-center gap-1">
|
||||||
|
<Icon icon="Code" size="0.75rem" inverted color="tertiary" />
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="s"
|
||||||
|
inverted
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{props.instance.resolved.usage_ref.name}
|
||||||
|
</Typography>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Services = () => {
|
||||||
|
const ctx = useClanContext();
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("ClanContext not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceInstances = () => {
|
||||||
|
if (!ctx.serviceInstancesQuery.isSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(ctx.serviceInstancesQuery.data)
|
||||||
|
.map(([id, instance]) => {
|
||||||
|
const moduleName = instance.module.name;
|
||||||
|
|
||||||
|
const label = moduleName == id ? moduleName : `${moduleName} (${id})`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
instance: instance,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item class="item" value="services">
|
||||||
|
<Accordion.Header class="header">
|
||||||
|
<Accordion.Trigger class="trigger">
|
||||||
|
<Typography
|
||||||
|
class="section-title"
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="xs"
|
||||||
|
inverted
|
||||||
|
color="tertiary"
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
</Typography>
|
||||||
|
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content class="content">
|
||||||
|
<nav>
|
||||||
|
<For each={serviceInstances()}>
|
||||||
|
{(mapped) => (
|
||||||
|
<ServiceRoute
|
||||||
|
clanURI={ctx.clanURI}
|
||||||
|
id={mapped.id}
|
||||||
|
label={mapped.label}
|
||||||
|
instance={mapped.instance}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidebarBody = (props: SidebarProps) => {
|
||||||
|
const sectionLabels = (props.staticSections || []).map(
|
||||||
|
(section) => section.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
// controls which sections are open by default
|
||||||
|
// we want them all to be open by default
|
||||||
|
const defaultAccordionValues = ["machines", "services", ...sectionLabels];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body">
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -85,66 +248,8 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
multiple
|
multiple
|
||||||
defaultValue={defaultAccordionValues}
|
defaultValue={defaultAccordionValues}
|
||||||
>
|
>
|
||||||
<Accordion.Item class="item" value="your-machines">
|
<Machines />
|
||||||
<Accordion.Header class="header">
|
<Services />
|
||||||
<Accordion.Trigger class="trigger">
|
|
||||||
<Typography
|
|
||||||
class="section-title"
|
|
||||||
hierarchy="label"
|
|
||||||
family="mono"
|
|
||||||
size="xs"
|
|
||||||
inverted={true}
|
|
||||||
color="tertiary"
|
|
||||||
>
|
|
||||||
Your Machines
|
|
||||||
</Typography>
|
|
||||||
<Icon
|
|
||||||
icon="CaretDown"
|
|
||||||
color="tertiary"
|
|
||||||
inverted={true}
|
|
||||||
size="0.75rem"
|
|
||||||
/>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</Accordion.Header>
|
|
||||||
<Accordion.Content class="content">
|
|
||||||
<Show
|
|
||||||
when={machines()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="s"
|
|
||||||
weight="medium"
|
|
||||||
inverted
|
|
||||||
>
|
|
||||||
No machines yet
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
hierarchy="primary"
|
|
||||||
size="s"
|
|
||||||
startIcon="Machine"
|
|
||||||
onClick={() => ctx.setShowAddMachine(true)}
|
|
||||||
>
|
|
||||||
Add machine
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<nav>
|
|
||||||
<For each={Object.entries(machines()!)}>
|
|
||||||
{([id, machine]) => (
|
|
||||||
<MachineRoute
|
|
||||||
clanURI={clanURI}
|
|
||||||
machineID={id}
|
|
||||||
name={machine.name || id}
|
|
||||||
serviceCount={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</nav>
|
|
||||||
</Show>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
|
|
||||||
<For each={props.staticSections}>
|
<For each={props.staticSections}>
|
||||||
{(section) => (
|
{(section) => (
|
||||||
@@ -156,7 +261,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
family="mono"
|
family="mono"
|
||||||
size="xs"
|
size="xs"
|
||||||
inverted={true}
|
inverted
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
>
|
>
|
||||||
{section.title}
|
{section.title}
|
||||||
@@ -164,7 +269,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
<Icon
|
<Icon
|
||||||
icon="CaretDown"
|
icon="CaretDown"
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
inverted={true}
|
inverted
|
||||||
size="0.75rem"
|
size="0.75rem"
|
||||||
/>
|
/>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
@@ -179,7 +284,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
weight="bold"
|
weight="bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
inverted={true}
|
inverted
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as v from "valibot";
|
|||||||
import { splitProps } from "solid-js";
|
import { splitProps } from "solid-js";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||||
|
import { setValue } from "@modular-forms/solid";
|
||||||
|
|
||||||
type Story = StoryObj<SidebarPaneProps>;
|
type Story = StoryObj<SidebarPaneProps>;
|
||||||
|
|
||||||
@@ -137,18 +138,21 @@ export const Default: Story = {
|
|||||||
console.log("saving tags", values);
|
console.log("saving tags", values);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ editing, Field }) => (
|
{({ editing, Field, formStore }) => (
|
||||||
<Field name="tags" type="string[]">
|
<Field name="tags" type="string[]">
|
||||||
{(field, input) => (
|
{(field, props) => (
|
||||||
<MachineTags
|
<MachineTags
|
||||||
{...splitProps(field, ["value"])[1]}
|
{...splitProps(field, ["value"])[1]}
|
||||||
size="s"
|
size="s"
|
||||||
|
onChange={(newVal) => {
|
||||||
|
// Workaround for now, until we manage to use native events
|
||||||
|
setValue(formStore, field.name, newVal);
|
||||||
|
}}
|
||||||
inverted
|
inverted
|
||||||
required
|
required
|
||||||
readOnly={!editing}
|
readOnly={!editing}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
input={input}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createSignal, JSX, Show } from "solid-js";
|
|||||||
import {
|
import {
|
||||||
createForm,
|
createForm,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
|
FormStore,
|
||||||
getErrors,
|
getErrors,
|
||||||
Maybe,
|
Maybe,
|
||||||
PartialValues,
|
PartialValues,
|
||||||
@@ -25,6 +26,7 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
|
|||||||
children: (ctx: {
|
children: (ctx: {
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
|
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
|
||||||
|
formStore: FormStore<FormValues>;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ export function SidebarSectionForm<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
||||||
|
console.log("Submitting SidebarForm", values);
|
||||||
|
|
||||||
await props.onSubmit(values);
|
await props.onSubmit(values);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
@@ -109,7 +113,7 @@ export function SidebarSectionForm<
|
|||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
{props.children({ editing: editing(), Field })}
|
{props.children({ editing: editing(), Field, formStore })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMachineName } from "@/src/hooks/clan";
|
|||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import styles from "./SidebarSectionInstall.module.css";
|
import styles from "./SidebarSectionInstall.module.css";
|
||||||
import { Alert } from "../Alert/Alert";
|
import { Alert } from "../Alert/Alert";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
|
||||||
export interface SidebarSectionInstallProps {
|
export interface SidebarSectionInstallProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -12,8 +13,8 @@ export interface SidebarSectionInstallProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||||
|
const ctx = useClanContext();
|
||||||
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||||
|
|
||||||
const [showInstall, setShowModal] = createSignal(false);
|
const [showInstall, setShowModal] = createSignal(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +33,20 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
|||||||
<InstallModal
|
<InstallModal
|
||||||
open={showInstall()}
|
open={showInstall()}
|
||||||
machineName={useMachineName()}
|
machineName={useMachineName()}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={async () => {
|
||||||
|
// trigger machine state refresh and wait for it
|
||||||
|
const machineState = useMachineStateQuery(
|
||||||
|
props.clanURI,
|
||||||
|
props.machineName,
|
||||||
|
);
|
||||||
|
await machineState.refetch();
|
||||||
|
|
||||||
|
// trigger more state to refresh but not wait for it
|
||||||
|
ctx.machinesQuery.refetch();
|
||||||
|
ctx.serviceInstancesQuery.refetch();
|
||||||
|
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import { useMachineName } from "@/src/hooks/clan";
|
||||||
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
|
import styles from "./SidebarSectionInstall.module.css";
|
||||||
|
import { UpdateModal } from "@/src/workflows/InstallMachine/UpdateMachine";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
|
||||||
|
export interface SidebarSectionUpdateProps {
|
||||||
|
clanURI: string;
|
||||||
|
machineName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarSectionUpdate = (props: SidebarSectionUpdateProps) => {
|
||||||
|
const ctx = useClanContext();
|
||||||
|
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||||
|
|
||||||
|
const [showUpdate, setShowUpdate] = createSignal(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={query.isSuccess && query.data.status !== "not_installed"}>
|
||||||
|
<div class={styles.install}>
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
size="s"
|
||||||
|
onClick={() => setShowUpdate(true)}
|
||||||
|
>
|
||||||
|
Update machine
|
||||||
|
</Button>
|
||||||
|
<Show when={showUpdate()}>
|
||||||
|
<UpdateModal
|
||||||
|
open={showUpdate()}
|
||||||
|
machineName={useMachineName()}
|
||||||
|
onClose={async () => {
|
||||||
|
// refresh some queries
|
||||||
|
ctx.machinesQuery.refetch();
|
||||||
|
ctx.serviceInstancesQuery.refetch();
|
||||||
|
|
||||||
|
setShowUpdate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { callApi } from "@/src/hooks/api";
|
import { callApi } from "@/src/hooks/api";
|
||||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||||
import { Params, Navigator, useParams } from "@solidjs/router";
|
import { Params, Navigator, useParams, useSearchParams } from "@solidjs/router";
|
||||||
|
|
||||||
export const encodeBase64 = (value: string) => window.btoa(value);
|
export const encodeBase64 = (value: string) => window.btoa(value);
|
||||||
export const decodeBase64 = (value: string) => window.atob(value);
|
export const decodeBase64 = (value: string) => window.atob(value);
|
||||||
@@ -30,6 +30,47 @@ export const buildClanPath = (clanURI: string) => {
|
|||||||
export const buildMachinePath = (clanURI: string, name: string) =>
|
export const buildMachinePath = (clanURI: string, name: string) =>
|
||||||
buildClanPath(clanURI) + "/machines/" + name;
|
buildClanPath(clanURI) + "/machines/" + name;
|
||||||
|
|
||||||
|
export const buildServicePath = (props: {
|
||||||
|
clanURI: string;
|
||||||
|
id: string;
|
||||||
|
module: {
|
||||||
|
name: string;
|
||||||
|
input?: string | null | undefined;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const { clanURI, id, module } = props;
|
||||||
|
|
||||||
|
const moduleName = encodeBase64(module.name);
|
||||||
|
const idEncoded = encodeBase64(id);
|
||||||
|
|
||||||
|
const result =
|
||||||
|
buildClanPath(clanURI) +
|
||||||
|
`/services/${moduleName}/${idEncoded}` +
|
||||||
|
(module.input ? `?input=${module.input}` : "");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useServiceParams = () => {
|
||||||
|
const params = useParams<{
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [search] = useSearchParams<{ input?: string }>();
|
||||||
|
|
||||||
|
if (!params.name || !params.id) {
|
||||||
|
console.error("Service params not found", params, window.location.pathname);
|
||||||
|
throw new Error("Service params not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: decodeBase64(params.name),
|
||||||
|
id: decodeBase64(params.id),
|
||||||
|
input: search.input,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
||||||
const path = buildClanPath(clanURI);
|
const path = buildClanPath(clanURI);
|
||||||
console.log("Navigating to clan", clanURI, path);
|
console.log("Navigating to clan", clanURI, path);
|
||||||
@@ -64,7 +105,21 @@ export const machineNameParam = (params: Params) => {
|
|||||||
return params.machineName;
|
return params.machineName;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const inputParam = (params: Params) => params.input;
|
||||||
|
export const nameParam = (params: Params) => params.name;
|
||||||
|
export const idParam = (params: Params) => params.id;
|
||||||
|
|
||||||
export const useMachineName = (): string => machineNameParam(useParams());
|
export const useMachineName = (): string => machineNameParam(useParams());
|
||||||
|
export const useInputParam = (): string => inputParam(useParams());
|
||||||
|
export const useNameParam = (): string => nameParam(useParams());
|
||||||
|
|
||||||
|
export const maybeUseIdParam = (): string | null => {
|
||||||
|
const params = useParams();
|
||||||
|
if (params.id === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return idParam(params);
|
||||||
|
};
|
||||||
|
|
||||||
export const maybeUseMachineName = (): string | null => {
|
export const maybeUseMachineName = (): string | null => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export type MachineStatus = MachineState["status"];
|
|||||||
export type ListMachines = SuccessData<"list_machines">;
|
export type ListMachines = SuccessData<"list_machines">;
|
||||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||||
|
|
||||||
|
export type ListServiceModules = SuccessData<"list_service_modules">;
|
||||||
|
export type ListServiceInstances = SuccessData<"list_service_instances">;
|
||||||
|
|
||||||
export interface MachineDetail {
|
export interface MachineDetail {
|
||||||
tags: Tags;
|
tags: Tags;
|
||||||
machine: Machine;
|
machine: Machine;
|
||||||
@@ -47,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => {
|
|||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
|
|
||||||
return useQuery<ListMachines>(() => ({
|
return useQuery<ListMachines>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
queryKey: [...clanKey(clanURI), "machines"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const api = client.fetch("list_machines", {
|
const api = client.fetch("list_machines", {
|
||||||
flake: {
|
flake: {
|
||||||
@@ -64,10 +67,16 @@ export const useMachinesQuery = (clanURI: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const machineKey = (clanUri: string, machineName: string) => [
|
||||||
|
...clanKey(clanUri),
|
||||||
|
"machine",
|
||||||
|
encodeBase64(machineName),
|
||||||
|
];
|
||||||
|
|
||||||
export const useMachineQuery = (clanURI: string, machineName: string) => {
|
export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineDetail>(() => ({
|
return useQuery<MachineDetail>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
queryKey: [machineKey(clanURI, machineName)],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [tagsCall, machineCall, schemaCall] = [
|
const [tagsCall, machineCall, schemaCall] = [
|
||||||
client.fetch("list_tags", {
|
client.fetch("list_tags", {
|
||||||
@@ -122,7 +131,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
|
|||||||
export const useTags = (clanURI: string) => {
|
export const useTags = (clanURI: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery(() => ({
|
return useQuery(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "tags"],
|
queryKey: [...clanKey(clanURI), "tags"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const apiCall = client.fetch("list_tags", {
|
const apiCall = client.fetch("list_tags", {
|
||||||
flake: {
|
flake: {
|
||||||
@@ -142,8 +151,7 @@ export const useTags = (clanURI: string) => {
|
|||||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineState>(() => ({
|
return useQuery<MachineState>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
queryKey: [...machineKey(clanURI, machineName), "state"],
|
||||||
staleTime: 60_000, // 1 minute stale time
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const apiCall = client.fetch("get_machine_state", {
|
const apiCall = client.fetch("get_machine_state", {
|
||||||
machine: {
|
machine: {
|
||||||
@@ -166,13 +174,61 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useServiceModulesQuery = (clanURI: string) => {
|
||||||
|
const client = useApiClient();
|
||||||
|
|
||||||
|
return useQuery<ListServiceModules>(() => ({
|
||||||
|
queryKey: [...clanKey(clanURI), "service_modules"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = client.fetch("list_service_modules", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
if (result.status === "error") {
|
||||||
|
throw new Error(
|
||||||
|
"Error fetching service modules: " + result.errors[0].message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useServiceInstancesQuery = (clanURI: string) => {
|
||||||
|
const client = useApiClient();
|
||||||
|
|
||||||
|
return useQuery<ListServiceInstances>(() => ({
|
||||||
|
queryKey: [...clanKey(clanURI), "service_instances"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = client.fetch("list_service_instances", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
if (result.status === "error") {
|
||||||
|
throw new Error(
|
||||||
|
"Error fetching service instances: " + result.errors[0].message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export const useMachineDetailsQuery = (
|
export const useMachineDetailsQuery = (
|
||||||
clanURI: string,
|
clanURI: string,
|
||||||
machineName: string,
|
machineName: string,
|
||||||
) => {
|
) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineDetails>(() => ({
|
return useQuery<MachineDetails>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
queryKey: [machineKey(clanURI, machineName), "details"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const call = client.fetch("get_machine_details", {
|
const call = client.fetch("get_machine_details", {
|
||||||
machine: {
|
machine: {
|
||||||
@@ -202,7 +258,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
|
|||||||
export const useClanDetailsQuery = (clanURI: string) => {
|
export const useClanDetailsQuery = (clanURI: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<ClanDetails>(() => ({
|
return useQuery<ClanDetails>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
queryKey: [...clanKey(clanURI), "details"],
|
||||||
persister: ClanDetailsPersister.persisterFn,
|
persister: ClanDetailsPersister.persisterFn,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const args = {
|
const args = {
|
||||||
@@ -253,7 +309,8 @@ export const useClanListQuery = (
|
|||||||
|
|
||||||
return useQueries(() => ({
|
return useQueries(() => ({
|
||||||
queries: clanURIs.map((clanURI) => {
|
queries: clanURIs.map((clanURI) => {
|
||||||
const queryKey = ["clans", encodeBase64(clanURI), "details"];
|
// @BMG: Is duplicating query key intentional?
|
||||||
|
const queryKey = [...clanKey(clanURI), "details"];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||||
@@ -322,7 +379,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
|
|||||||
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
|
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineFlashOptions>(() => ({
|
return useQuery<MachineFlashOptions>(() => ({
|
||||||
queryKey: ["clans", "machine_flash_options"],
|
queryKey: ["flash_options"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const call = client.fetch("get_machine_flash_options", {});
|
const call = client.fetch("get_machine_flash_options", {});
|
||||||
const result = await call.result;
|
const result = await call.result;
|
||||||
@@ -456,12 +513,14 @@ export const useMachineGenerators = (
|
|||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const call = client.fetch("get_generators", {
|
const call = client.fetch("get_generators", {
|
||||||
machine: {
|
machines: [
|
||||||
name: machineName,
|
{
|
||||||
flake: {
|
name: machineName,
|
||||||
identifier: clanUri,
|
flake: {
|
||||||
|
identifier: clanUri,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
full_closure: true, // TODO: Make this configurable
|
full_closure: true, // TODO: Make this configurable
|
||||||
// TODO: Make this configurable
|
// TODO: Make this configurable
|
||||||
include_previous_values: true,
|
include_previous_values: true,
|
||||||
@@ -484,7 +543,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
|
|||||||
export const useServiceModules = (clanUri: string) => {
|
export const useServiceModules = (clanUri: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery(() => ({
|
return useQuery(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
queryKey: [...clanKey(clanUri), "service_modules"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const call = client.fetch("list_service_modules", {
|
const call = client.fetch("list_service_modules", {
|
||||||
flake: {
|
flake: {
|
||||||
@@ -504,12 +563,14 @@ export const useServiceModules = (clanUri: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
|
||||||
|
|
||||||
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
||||||
export type ServiceInstances = SuccessData<"list_service_instances">;
|
export type ServiceInstances = SuccessData<"list_service_instances">;
|
||||||
export const useServiceInstances = (clanUri: string) => {
|
export const useServiceInstances = (clanUri: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery(() => ({
|
return useQuery(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
queryKey: [...clanKey(clanUri), "service_instances"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const call = client.fetch("list_service_instances", {
|
const call = client.fetch("list_service_instances", {
|
||||||
flake: {
|
flake: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
createContext,
|
createContext,
|
||||||
@@ -17,13 +17,15 @@ import {
|
|||||||
useClanURI,
|
useClanURI,
|
||||||
useMachineName,
|
useMachineName,
|
||||||
} from "@/src/hooks/clan";
|
} from "@/src/hooks/clan";
|
||||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
import { CubeScene } from "@/src/scene/cubes";
|
||||||
import {
|
import {
|
||||||
ClanDetails,
|
ClanDetails,
|
||||||
|
ListServiceInstances,
|
||||||
MachinesQueryResult,
|
MachinesQueryResult,
|
||||||
useClanDetailsQuery,
|
useClanDetailsQuery,
|
||||||
useClanListQuery,
|
useClanListQuery,
|
||||||
useMachinesQuery,
|
useMachinesQuery,
|
||||||
|
useServiceInstancesQuery,
|
||||||
} from "@/src/hooks/queries";
|
} from "@/src/hooks/queries";
|
||||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
@@ -33,37 +35,27 @@ import styles from "./Clan.module.css";
|
|||||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
import { UseQueryResult } from "@tanstack/solid-query";
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||||
import {
|
|
||||||
ServiceWorkflow,
|
|
||||||
SubmitServiceHandler,
|
|
||||||
} from "@/src/workflows/Service/Service";
|
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
|
||||||
import toast from "solid-toast";
|
|
||||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
|
||||||
|
|
||||||
interface ClanContextProps {
|
export type WorldMode = "default" | "select" | "service" | "create" | "move";
|
||||||
clanURI: string;
|
|
||||||
machinesQuery: MachinesQueryResult;
|
|
||||||
activeClanQuery: UseQueryResult<ClanDetails>;
|
|
||||||
otherClanQueries: UseQueryResult<ClanDetails>[];
|
|
||||||
allClansQueries: UseQueryResult<ClanDetails>[];
|
|
||||||
|
|
||||||
isLoading(): boolean;
|
|
||||||
isError(): boolean;
|
|
||||||
|
|
||||||
showAddMachine(): boolean;
|
|
||||||
setShowAddMachine(value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createClanContext(
|
function createClanContext(
|
||||||
clanURI: string,
|
clanURI: string,
|
||||||
machinesQuery: MachinesQueryResult,
|
machinesQuery: MachinesQueryResult,
|
||||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||||
|
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
|
||||||
) {
|
) {
|
||||||
|
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
|
||||||
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||||
const allQueries = [machinesQuery, ...allClansQueries];
|
const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clanURI,
|
clanURI,
|
||||||
@@ -71,14 +63,23 @@ function createClanContext(
|
|||||||
activeClanQuery,
|
activeClanQuery,
|
||||||
otherClanQueries,
|
otherClanQueries,
|
||||||
allClansQueries,
|
allClansQueries,
|
||||||
|
serviceInstancesQuery,
|
||||||
isLoading: () => allQueries.some((q) => q.isLoading),
|
isLoading: () => allQueries.some((q) => q.isLoading),
|
||||||
isError: () => activeClanQuery.isError,
|
isError: () => activeClanQuery.isError,
|
||||||
showAddMachine,
|
showAddMachine,
|
||||||
setShowAddMachine,
|
setShowAddMachine,
|
||||||
|
navigateToRoot: () => {
|
||||||
|
if (location.pathname === buildClanPath(clanURI)) return;
|
||||||
|
navigate(buildClanPath(clanURI), { replace: true });
|
||||||
|
},
|
||||||
|
setWorldMode,
|
||||||
|
worldMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClanContext = createContext<ClanContextProps>();
|
const ClanContext = createContext<
|
||||||
|
ReturnType<typeof createClanContext> | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
export const useClanContext = () => {
|
export const useClanContext = () => {
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useContext(ClanContext);
|
||||||
@@ -104,12 +105,14 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const machinesQuery = useMachinesQuery(clanURI);
|
const machinesQuery = useMachinesQuery(clanURI);
|
||||||
|
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
|
||||||
|
|
||||||
const ctx = createClanContext(
|
const ctx = createClanContext(
|
||||||
clanURI,
|
clanURI,
|
||||||
machinesQuery,
|
machinesQuery,
|
||||||
activeClanQuery,
|
activeClanQuery,
|
||||||
otherClanQueries,
|
otherClanQueries,
|
||||||
|
serviceInstancesQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,8 +135,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [showService, setShowService] = createSignal(false);
|
|
||||||
|
|
||||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||||
resolve: ({ id }: { id: string }) => void;
|
resolve: ({ id }: { id: string }) => void;
|
||||||
reject: (err: unknown) => void;
|
reject: (err: unknown) => void;
|
||||||
@@ -194,45 +195,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const client = useApiClient();
|
const location = useLocation();
|
||||||
const handleSubmitService: SubmitServiceHandler = async (
|
|
||||||
instance,
|
|
||||||
action,
|
|
||||||
) => {
|
|
||||||
console.log(action, "Instance", instance);
|
|
||||||
|
|
||||||
if (action !== "create") {
|
|
||||||
toast.error("Only creating new services is supported");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const call = client.fetch("create_service_instance", {
|
|
||||||
flake: {
|
|
||||||
identifier: ctx.clanURI,
|
|
||||||
},
|
|
||||||
module_ref: instance.module,
|
|
||||||
roles: instance.roles,
|
|
||||||
});
|
|
||||||
const result = await call.result;
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
toast.error("Error creating service instance");
|
|
||||||
console.error("Error creating service instance", result.errors);
|
|
||||||
}
|
|
||||||
toast.success("Created");
|
|
||||||
setShowService(false);
|
|
||||||
setWorldMode("select");
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(worldMode, (mode) => {
|
|
||||||
if (mode === "service") {
|
|
||||||
setShowService(true);
|
|
||||||
} else {
|
|
||||||
// TODO: request soft close instead of forced close
|
|
||||||
setShowService(false);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -268,15 +231,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
isLoading={ctx.isLoading()}
|
isLoading={ctx.isLoading()}
|
||||||
cubesQuery={ctx.machinesQuery}
|
cubesQuery={ctx.machinesQuery}
|
||||||
toolbarPopup={
|
toolbarPopup={
|
||||||
<Show when={showService()}>
|
<Show when={ctx.worldMode() === "service"}>
|
||||||
<ServiceWorkflow
|
<Show
|
||||||
handleSubmit={handleSubmitService}
|
when={location.pathname.includes("/services/")}
|
||||||
onClose={() => {
|
fallback={
|
||||||
setShowService(false);
|
<SelectService
|
||||||
setWorldMode("select");
|
onClose={() => {
|
||||||
currentPromise()?.resolve({ id: "0" });
|
ctx.setWorldMode("select");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { SectionGeneral } from "./SectionGeneral";
|
|||||||
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
||||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||||
import { callApi } from "@/src/hooks/api";
|
import { callApi } from "@/src/hooks/api";
|
||||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
|
||||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||||
|
|
||||||
import styles from "./Machine.module.css";
|
import styles from "./Machine.module.css";
|
||||||
|
import { SectionServices } from "@/src/routes/Machine/SectionServices";
|
||||||
|
import { SidebarSectionUpdate } from "@/src/components/Sidebar/SidebarSectionUpdate";
|
||||||
|
|
||||||
export const Machine = (props: RouteSectionProps) => {
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -20,13 +21,16 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
navigateToClan(navigate, clanURI);
|
navigateToClan(navigate, clanURI);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sections = () => {
|
const Sections = () => {
|
||||||
const machineName = useMachineName();
|
const machineName = useMachineName();
|
||||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||||
|
|
||||||
|
console.log("machineName", machineName);
|
||||||
|
|
||||||
// we have to update the whole machine model rather than just the sub fields that were changed
|
// we have to update the whole machine model rather than just the sub fields that were changed
|
||||||
// for that reason we pass in this common submit handler to each machine sub section
|
// for that reason we pass in this common submit handler to each machine sub section
|
||||||
const onSubmit = async (values: Partial<MachineModel>) => {
|
const onSubmit = async (values: Partial<MachineModel>) => {
|
||||||
|
console.log("saving tags", values);
|
||||||
const call = callApi("set_machine", {
|
const call = callApi("set_machine", {
|
||||||
machine: {
|
machine: {
|
||||||
name: machineName,
|
name: machineName,
|
||||||
@@ -57,8 +61,13 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
clanURI={clanURI}
|
clanURI={clanURI}
|
||||||
machineName={useMachineName()}
|
machineName={useMachineName()}
|
||||||
/>
|
/>
|
||||||
|
<SidebarSectionUpdate
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineName={useMachineName()}
|
||||||
|
/>
|
||||||
<SectionGeneral {...sectionProps} />
|
<SectionGeneral {...sectionProps} />
|
||||||
<SectionTags {...sectionProps} />
|
<SectionTags {...sectionProps} />
|
||||||
|
<SectionServices />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -69,16 +78,19 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
<SidebarPane
|
<SidebarPane
|
||||||
title={useMachineName()}
|
title={useMachineName()}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
subHeader={
|
// the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and
|
||||||
<Show when={useMachineName()} keyed>
|
// present the user with the ability to install or update a machines based on `installedAt` in the inventory.json
|
||||||
<SidebarMachineStatus
|
//
|
||||||
clanURI={clanURI}
|
// subHeader={
|
||||||
machineName={useMachineName()}
|
// <Show when={useMachineName()} keyed>
|
||||||
/>
|
// <SidebarMachineStatus
|
||||||
</Show>
|
// clanURI={clanURI}
|
||||||
}
|
// machineName={useMachineName()}
|
||||||
|
// />
|
||||||
|
// </Show>
|
||||||
|
// }
|
||||||
>
|
>
|
||||||
{sections()}
|
{Sections()}
|
||||||
</SidebarPane>
|
</SidebarPane>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.sectionServices {
|
||||||
|
@apply overflow-hidden flex flex-col;
|
||||||
|
@apply bg-inv-4 rounded-md;
|
||||||
|
|
||||||
|
nav * {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav > a {
|
||||||
|
@apply block w-full px-2 py-1.5 min-h-7 my-2 rounded-md;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
@apply mt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme(colors.secondary.900),
|
||||||
|
60%,
|
||||||
|
theme(colors.secondary.600) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-inv-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
@apply bg-inv-acc-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
@apply bg-inv-acc-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx
Normal file
53
pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { SidebarSection } from "@/src/components/Sidebar/SidebarSection";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
import { For, Show } from "solid-js";
|
||||||
|
import { useMachineName } from "@/src/hooks/clan";
|
||||||
|
import { ServiceRoute } from "@/src/components/Sidebar/SidebarBody";
|
||||||
|
import styles from "./SectionServices.module.css";
|
||||||
|
|
||||||
|
export const SectionServices = () => {
|
||||||
|
const ctx = useClanContext();
|
||||||
|
|
||||||
|
const services = () => {
|
||||||
|
if (!(ctx.machinesQuery.isSuccess && ctx.serviceInstancesQuery.isSuccess)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineName = useMachineName();
|
||||||
|
if (!ctx.machinesQuery.data[machineName]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (ctx.machinesQuery.data[machineName].instance_refs ?? [])
|
||||||
|
.map((id) => {
|
||||||
|
const instance = ctx.serviceInstancesQuery.data?.[id];
|
||||||
|
if (!instance) {
|
||||||
|
throw new Error(`Service instance ${id} not found`);
|
||||||
|
}
|
||||||
|
const module = instance.module;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
instance,
|
||||||
|
label: module.name == id ? module.name : `${module.name} (${id})`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={ctx.serviceInstancesQuery.isSuccess}>
|
||||||
|
<SidebarSection title="Services">
|
||||||
|
<div class={styles.sectionServices}>
|
||||||
|
<nav>
|
||||||
|
<For each={services()}>
|
||||||
|
{(instance) => (
|
||||||
|
<ServiceRoute clanURI={ctx.clanURI} {...instance} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</SidebarSection>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
|
|||||||
import { pick } from "@/src/util";
|
import { pick } from "@/src/util";
|
||||||
import { UseQueryResult } from "@tanstack/solid-query";
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||||
|
import { setValue } from "@modular-forms/solid";
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = v.object({
|
||||||
tags: v.pipe(v.optional(v.array(v.string()))),
|
tags: v.pipe(v.optional(v.array(v.string()))),
|
||||||
@@ -32,7 +33,7 @@ export const SectionTags = (props: SectionTags) => {
|
|||||||
|
|
||||||
const options = () => {
|
const options = () => {
|
||||||
if (!machineQuery.isSuccess) {
|
if (!machineQuery.isSuccess) {
|
||||||
return [[], []];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// these are static values or values which have been configured in nix and
|
// these are static values or values which have been configured in nix and
|
||||||
@@ -58,7 +59,7 @@ export const SectionTags = (props: SectionTags) => {
|
|||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
initialValues={initialValues()}
|
initialValues={initialValues()}
|
||||||
>
|
>
|
||||||
{({ editing, Field }) => (
|
{({ editing, Field, formStore }) => (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<Field name="tags" type="string[]">
|
<Field name="tags" type="string[]">
|
||||||
{(field, input) => (
|
{(field, input) => (
|
||||||
@@ -72,7 +73,10 @@ export const SectionTags = (props: SectionTags) => {
|
|||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
defaultOptions={options()[0]}
|
defaultOptions={options()[0]}
|
||||||
readonlyOptions={options()[1]}
|
readonlyOptions={options()[1]}
|
||||||
input={input}
|
onChange={(newVal) => {
|
||||||
|
// Workaround for now, until we manage to use native events
|
||||||
|
setValue(formStore, field.name, newVal);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
59
pkgs/clan-app/ui/src/routes/Service/Service.tsx
Normal file
59
pkgs/clan-app/ui/src/routes/Service/Service.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
import { ServiceWorkflow } from "@/src/workflows/Service/Service";
|
||||||
|
import { SubmitServiceHandler } from "@/src/workflows/Service/models";
|
||||||
|
import { buildClanPath } from "@/src/hooks/clan";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { useQueryClient } from "@tanstack/solid-query";
|
||||||
|
import { clanKey } from "@/src/hooks/queries";
|
||||||
|
import { onMount } from "solid-js";
|
||||||
|
|
||||||
|
export const Service = (props: RouteSectionProps) => {
|
||||||
|
const ctx = useClanContext();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const client = useApiClient();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ctx.setWorldMode("service");
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitServiceHandler = async (instance, action) => {
|
||||||
|
console.log("Service submitted", instance, action);
|
||||||
|
|
||||||
|
if (action !== "create") {
|
||||||
|
console.warn("Updating service instances is not supported yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = client.fetch("create_service_instance", {
|
||||||
|
flake: {
|
||||||
|
identifier: ctx.clanURI,
|
||||||
|
},
|
||||||
|
module_ref: instance.module,
|
||||||
|
roles: instance.roles,
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
console.error("Error creating service instance", result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: clanKey(ctx.clanURI),
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.setWorldMode("select");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
console.log("Service closed, navigating back");
|
||||||
|
navigate(buildClanPath(ctx.clanURI), { replace: true });
|
||||||
|
ctx.setWorldMode("select");
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ServiceWorkflow handleSubmit={handleSubmit} onClose={handleClose} />;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { RouteDefinition } from "@solidjs/router/dist/types";
|
|||||||
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
||||||
import { Clan } from "@/src/routes/Clan/Clan";
|
import { Clan } from "@/src/routes/Clan/Clan";
|
||||||
import { Machine } from "@/src/routes/Machine/Machine";
|
import { Machine } from "@/src/routes/Machine/Machine";
|
||||||
|
import { Service } from "@/src/routes/Service/Service";
|
||||||
|
|
||||||
export const Routes: RouteDefinition[] = [
|
export const Routes: RouteDefinition[] = [
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,15 @@ export const Routes: RouteDefinition[] = [
|
|||||||
{
|
{
|
||||||
path: "/machines/:machineName",
|
path: "/machines/:machineName",
|
||||||
component: Machine,
|
component: Machine,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/services/:name/:id",
|
||||||
|
component: Service,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { ObjectRegistry } from "./ObjectRegistry";
|
import { ObjectRegistry } from "./ObjectRegistry";
|
||||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
|
||||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
// @ts-expect-error: No types for troika-three-text
|
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
||||||
import { Text } from "troika-three-text";
|
import { FontLoader } from "three/examples/jsm/Addons";
|
||||||
import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
|
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const BASE_SIZE = 0.9;
|
const BASE_SIZE = 0.9;
|
||||||
@@ -23,6 +22,71 @@ const BASE_EMISSIVE = 0x0c0c0c;
|
|||||||
const BASE_SELECTED_COLOR = 0x69b0e3;
|
const BASE_SELECTED_COLOR = 0x69b0e3;
|
||||||
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
||||||
|
|
||||||
|
export function createMachineMesh() {
|
||||||
|
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: CUBE_COLOR,
|
||||||
|
emissive: CUBE_EMISSIVE,
|
||||||
|
shininess: 100,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cubeMesh = new THREE.Mesh(geometry, material);
|
||||||
|
cubeMesh.castShadow = true;
|
||||||
|
cubeMesh.receiveShadow = true;
|
||||||
|
cubeMesh.name = "cube";
|
||||||
|
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
||||||
|
|
||||||
|
const { baseMesh, baseMaterial } = createCubeBase(
|
||||||
|
BASE_COLOR,
|
||||||
|
BASE_EMISSIVE,
|
||||||
|
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cubeMesh,
|
||||||
|
baseMesh,
|
||||||
|
baseMaterial,
|
||||||
|
geometry,
|
||||||
|
material,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCubeBase(
|
||||||
|
color: THREE.ColorRepresentation,
|
||||||
|
emissive: THREE.ColorRepresentation,
|
||||||
|
geometry: THREE.BoxGeometry,
|
||||||
|
) {
|
||||||
|
const baseMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color,
|
||||||
|
emissive,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
|
||||||
|
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
|
||||||
|
baseMesh.receiveShadow = false;
|
||||||
|
return { baseMesh, baseMaterial };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to build rounded rect shape
|
||||||
|
export function roundedRectShape(w: number, h: number, r: number) {
|
||||||
|
const shape = new THREE.Shape();
|
||||||
|
const x = -w / 2;
|
||||||
|
const y = -h / 2;
|
||||||
|
|
||||||
|
shape.moveTo(x + r, y);
|
||||||
|
shape.lineTo(x + w - r, y);
|
||||||
|
shape.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
shape.lineTo(x + w, y + h - r);
|
||||||
|
shape.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
shape.lineTo(x + r, y + h);
|
||||||
|
shape.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
shape.lineTo(x, y + r);
|
||||||
|
shape.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
export class MachineRepr {
|
export class MachineRepr {
|
||||||
public id: string;
|
public id: string;
|
||||||
public group: THREE.Group;
|
public group: THREE.Group;
|
||||||
@@ -46,31 +110,21 @@ export class MachineRepr {
|
|||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
|
||||||
this.material = new THREE.MeshPhongMaterial({
|
|
||||||
color: CUBE_COLOR,
|
|
||||||
emissive: CUBE_EMISSIVE,
|
|
||||||
shininess: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
|
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
|
||||||
this.cubeMesh.castShadow = true;
|
this.cubeMesh = cubeMesh;
|
||||||
this.cubeMesh.receiveShadow = true;
|
|
||||||
this.cubeMesh.userData = { id };
|
this.cubeMesh.userData = { id };
|
||||||
this.cubeMesh.name = "cube";
|
|
||||||
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
|
||||||
|
|
||||||
this.baseMesh = this.createCubeBase(
|
this.baseMesh = baseMesh;
|
||||||
BASE_COLOR,
|
|
||||||
BASE_EMISSIVE,
|
|
||||||
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
|
||||||
);
|
|
||||||
this.baseMesh.name = "base";
|
this.baseMesh.name = "base";
|
||||||
|
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
|
||||||
const label = this.createLabel(id);
|
const label = this.createLabel(id);
|
||||||
|
|
||||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||||
color: BASE_COLOR, // any color you like
|
color: BASE_COLOR,
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -104,8 +158,6 @@ export class MachineRepr {
|
|||||||
const highlightedGroups = groups
|
const highlightedGroups = groups
|
||||||
.filter(([, ids]) => ids.has(this.id))
|
.filter(([, ids]) => ids.has(this.id))
|
||||||
.map(([name]) => name);
|
.map(([name]) => name);
|
||||||
|
|
||||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
|
||||||
// Update cube
|
// Update cube
|
||||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||||
@@ -122,9 +174,6 @@ export class MachineRepr {
|
|||||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||||
);
|
);
|
||||||
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
|
||||||
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
|
||||||
// );
|
|
||||||
|
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
},
|
},
|
||||||
@@ -149,45 +198,85 @@ export class MachineRepr {
|
|||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createCubeBase(
|
|
||||||
color: THREE.ColorRepresentation,
|
|
||||||
emissive: THREE.ColorRepresentation,
|
|
||||||
geometry: THREE.BoxGeometry,
|
|
||||||
) {
|
|
||||||
const baseMaterial = new THREE.MeshPhongMaterial({
|
|
||||||
color,
|
|
||||||
emissive,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
const base = new THREE.Mesh(geometry, baseMaterial);
|
|
||||||
base.position.set(0, BASE_HEIGHT / 2, 0);
|
|
||||||
base.receiveShadow = false;
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createLabel(id: string) {
|
private createLabel(id: string) {
|
||||||
const text = new Text();
|
const group = new THREE.Group();
|
||||||
text.text = id;
|
// 0x162324
|
||||||
text.font = ttf;
|
// const text = new Text();
|
||||||
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
|
// text.text = id;
|
||||||
text.fontSize = 0.15; // relative to your cube size
|
// text.font = ttf;
|
||||||
text.color = 0x000000; // any THREE.Color
|
// text.fontSize = 0.1;
|
||||||
text.anchorX = "center"; // horizontal centering
|
// text.color = 0xffffff;
|
||||||
text.anchorY = "bottom"; // baseline aligns to cube top
|
// text.anchorX = "center";
|
||||||
text.position.set(0, CUBE_SIZE + 0.05, 0);
|
// text.anchorY = "middle";
|
||||||
|
// text.position.set(0, 0, 0.01);
|
||||||
|
// text.outlineWidth = 0.005;
|
||||||
|
// text.outlineColor = 0x162324;
|
||||||
|
// text.sync(() => {
|
||||||
|
// renderLoop.requestRender();
|
||||||
|
// });
|
||||||
|
|
||||||
// If you want it to always face camera:
|
const textMaterial = new THREE.MeshPhongMaterial({
|
||||||
text.userData.isLabel = true;
|
color: 0xffffff,
|
||||||
text.outlineWidth = 0.005;
|
|
||||||
text.outlineColor = 0x333333;
|
|
||||||
text.quaternion.copy(this.camera.quaternion);
|
|
||||||
|
|
||||||
// Re-render on text changes
|
|
||||||
text.sync(() => {
|
|
||||||
renderLoop.requestRender();
|
|
||||||
});
|
});
|
||||||
return text;
|
const textGeo = new TextGeometry(id, {
|
||||||
|
font: new FontLoader().parse(jsonfont),
|
||||||
|
size: 0.09,
|
||||||
|
depth: 0.001,
|
||||||
|
curveSegments: 12,
|
||||||
|
bevelEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = new THREE.Mesh(textGeo, textMaterial);
|
||||||
|
textGeo.computeBoundingBox();
|
||||||
|
|
||||||
|
const bbox = textGeo.boundingBox;
|
||||||
|
if (bbox) {
|
||||||
|
const xMid = -0.5 * (bbox.max.x - bbox.min.x);
|
||||||
|
// const yMid = -0.5 * (bbox.max.y - bbox.min.y);
|
||||||
|
// const zMid = -0.5 * (bbox.max.z - bbox.min.z);
|
||||||
|
|
||||||
|
// Translate geometry so center is at origin / baseline aligned with y=0
|
||||||
|
textGeo.translate(xMid, -0.035, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Background (rounded rect) ---
|
||||||
|
const padding = 0.04;
|
||||||
|
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1;
|
||||||
|
const bgWidth = textWidth + 10 * padding;
|
||||||
|
// const bgWidth = text.text.length * 0.07 + padding;
|
||||||
|
const bgHeight = 0.1 + 2 * padding;
|
||||||
|
const radius = 0.02;
|
||||||
|
|
||||||
|
const bgShape = roundedRectShape(bgWidth, bgHeight, radius);
|
||||||
|
const bgGeom = new THREE.ShapeGeometry(bgShape);
|
||||||
|
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 });
|
||||||
|
const bg = new THREE.Mesh(bgGeom, bgMat);
|
||||||
|
bg.position.set(0, 0, -0.01);
|
||||||
|
|
||||||
|
// --- Arrow (triangle pointing down) ---
|
||||||
|
const arrowShape = new THREE.Shape();
|
||||||
|
arrowShape.moveTo(-0.05, 0);
|
||||||
|
arrowShape.lineTo(0.05, 0);
|
||||||
|
arrowShape.lineTo(0, -0.05);
|
||||||
|
arrowShape.closePath();
|
||||||
|
|
||||||
|
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
|
||||||
|
const arrow = new THREE.Mesh(arrowGeom, bgMat);
|
||||||
|
arrow.position.set(0, -bgHeight / 2, -0.001);
|
||||||
|
|
||||||
|
// --- Group ---
|
||||||
|
group.add(bg);
|
||||||
|
group.add(arrow);
|
||||||
|
group.add(text);
|
||||||
|
|
||||||
|
// Position above cube
|
||||||
|
group.position.set(0, CUBE_SIZE + 0.3, 0);
|
||||||
|
|
||||||
|
// Billboard
|
||||||
|
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
|
||||||
|
group.quaternion.copy(this.camera.quaternion);
|
||||||
|
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(scene: THREE.Scene) {
|
dispose(scene: THREE.Scene) {
|
||||||
@@ -197,12 +286,13 @@ export class MachineRepr {
|
|||||||
|
|
||||||
this.geometry.dispose();
|
this.geometry.dispose();
|
||||||
this.material.dispose();
|
this.material.dispose();
|
||||||
|
|
||||||
|
this.group.clear();
|
||||||
|
|
||||||
for (const child of this.cubeMesh.children) {
|
for (const child of this.cubeMesh.children) {
|
||||||
if (child instanceof THREE.Mesh)
|
if (child instanceof THREE.Mesh)
|
||||||
(child.material as THREE.Material).dispose();
|
(child.material as THREE.Material).dispose();
|
||||||
|
|
||||||
if (child instanceof CSS2DObject) child.element.remove();
|
|
||||||
|
|
||||||
if (child instanceof THREE.Object3D) child.remove();
|
if (child instanceof THREE.Object3D) child.remove();
|
||||||
}
|
}
|
||||||
(this.baseMesh.material as THREE.Material).dispose();
|
(this.baseMesh.material as THREE.Material).dispose();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||||
<Show when={show()}> */
|
<Show when={show()}> */
|
||||||
.toolbar-container {
|
.toolbar-container {
|
||||||
@apply absolute bottom-10 z-10 w-full;
|
@apply absolute bottom-10 z-30 left-1/2;
|
||||||
@apply flex justify-center items-center;
|
@apply flex justify-center items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ import { MachineManager } from "./MachineManager";
|
|||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { Menu } from "../components/ContextMenu/ContextMenu";
|
import { Menu } from "../components/ContextMenu/ContextMenu";
|
||||||
import { clearHighlight, setHighlightGroups } from "./highlightStore";
|
import {
|
||||||
|
clearHighlight,
|
||||||
|
highlightGroups,
|
||||||
|
setHighlightGroups,
|
||||||
|
} from "./highlightStore";
|
||||||
|
import { createMachineMesh } from "./MachineRepr";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
|
||||||
function intersectMachines(
|
function intersectMachines(
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
@@ -33,7 +39,7 @@ function intersectMachines(
|
|||||||
camera: THREE.Camera,
|
camera: THREE.Camera,
|
||||||
machineManager: MachineManager,
|
machineManager: MachineManager,
|
||||||
raycaster: THREE.Raycaster,
|
raycaster: THREE.Raycaster,
|
||||||
): string[] {
|
) {
|
||||||
const rect = renderer.domElement.getBoundingClientRect();
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
const mouse = new THREE.Vector2(
|
const mouse = new THREE.Vector2(
|
||||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
@@ -44,7 +50,10 @@ function intersectMachines(
|
|||||||
Array.from(machineManager.machines.values().map((m) => m.group)),
|
Array.from(machineManager.machines.values().map((m) => m.group)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return intersects.map((i) => i.object.userData.id);
|
return {
|
||||||
|
machines: intersects.map((i) => i.object.userData.id),
|
||||||
|
intersection: intersects,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function garbageCollectGroup(group: THREE.Group) {
|
function garbageCollectGroup(group: THREE.Group) {
|
||||||
@@ -86,12 +95,6 @@ export function useMachineClick() {
|
|||||||
return lastClickedMachine;
|
return lastClickedMachine;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*Gloabl signal*/
|
|
||||||
const [worldMode, setWorldMode] = createSignal<
|
|
||||||
"default" | "select" | "service" | "create" | "move"
|
|
||||||
>("select");
|
|
||||||
export { worldMode, setWorldMode };
|
|
||||||
|
|
||||||
export function CubeScene(props: {
|
export function CubeScene(props: {
|
||||||
cubesQuery: MachinesQueryResult;
|
cubesQuery: MachinesQueryResult;
|
||||||
onCreate: () => Promise<{ id: string }>;
|
onCreate: () => Promise<{ id: string }>;
|
||||||
@@ -103,6 +106,8 @@ export function CubeScene(props: {
|
|||||||
clanURI: string;
|
clanURI: string;
|
||||||
toolbarPopup?: JSX.Element;
|
toolbarPopup?: JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
|
const ctx = useClanContext();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let scene: THREE.Scene;
|
let scene: THREE.Scene;
|
||||||
let camera: THREE.OrthographicCamera;
|
let camera: THREE.OrthographicCamera;
|
||||||
@@ -113,6 +118,7 @@ export function CubeScene(props: {
|
|||||||
// Raycaster for clicking
|
// Raycaster for clicking
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
let actionBase: THREE.Mesh | undefined;
|
let actionBase: THREE.Mesh | undefined;
|
||||||
|
let actionMachine: THREE.Group | undefined;
|
||||||
|
|
||||||
// Create background scene
|
// Create background scene
|
||||||
const bgScene = new THREE.Scene();
|
const bgScene = new THREE.Scene();
|
||||||
@@ -123,12 +129,17 @@ export function CubeScene(props: {
|
|||||||
let sharedCubeGeometry: THREE.BoxGeometry;
|
let sharedCubeGeometry: THREE.BoxGeometry;
|
||||||
let sharedBaseGeometry: THREE.BoxGeometry;
|
let sharedBaseGeometry: THREE.BoxGeometry;
|
||||||
|
|
||||||
|
let machineManager: MachineManager;
|
||||||
|
|
||||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||||
"grid",
|
"grid",
|
||||||
);
|
);
|
||||||
// Managed by controls
|
// Managed by controls
|
||||||
const [isDragging, setIsDragging] = createSignal(false);
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
|
const [cancelMove, setCancelMove] = createSignal<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
// TODO: Unify this with actionRepr position
|
||||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||||
|
|
||||||
const [cameraInfo, setCameraInfo] = createSignal({
|
const [cameraInfo, setCameraInfo] = createSignal({
|
||||||
@@ -300,12 +311,12 @@ export function CubeScene(props: {
|
|||||||
bgCamera,
|
bgCamera,
|
||||||
);
|
);
|
||||||
|
|
||||||
controls.addEventListener("start", (e) => {
|
// controls.addEventListener("start", (e) => {
|
||||||
setIsDragging(true);
|
// setIsDragging(true);
|
||||||
});
|
// });
|
||||||
controls.addEventListener("end", (e) => {
|
// controls.addEventListener("end", (e) => {
|
||||||
setIsDragging(false);
|
// setIsDragging(false);
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||||
@@ -384,6 +395,23 @@ export function CubeScene(props: {
|
|||||||
|
|
||||||
scene.add(actionBase);
|
scene.add(actionBase);
|
||||||
|
|
||||||
|
function createActionMachine() {
|
||||||
|
const { baseMesh, cubeMesh, material, baseMaterial } =
|
||||||
|
createMachineMesh();
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(baseMesh);
|
||||||
|
group.add(cubeMesh);
|
||||||
|
// group.scale.set(0.75, 0.75, 0.75);
|
||||||
|
material.opacity = 0.6;
|
||||||
|
baseMaterial.opacity = 0.3;
|
||||||
|
baseMaterial.emissive.set(MOVE_BASE_EMISSIVE);
|
||||||
|
// Hide until needed
|
||||||
|
group.visible = false;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
actionMachine = createActionMachine();
|
||||||
|
scene.add(actionMachine);
|
||||||
|
|
||||||
// const spherical = new THREE.Spherical();
|
// const spherical = new THREE.Spherical();
|
||||||
// spherical.setFromVector3(camera.position);
|
// spherical.setFromVector3(camera.position);
|
||||||
|
|
||||||
@@ -409,7 +437,7 @@ export function CubeScene(props: {
|
|||||||
updateCameraInfo();
|
updateCameraInfo();
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(worldMode, (mode) => {
|
on(ctx.worldMode, (mode) => {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
actionBase!.visible = true;
|
actionBase!.visible = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -421,7 +449,7 @@ export function CubeScene(props: {
|
|||||||
|
|
||||||
const registry = new ObjectRegistry();
|
const registry = new ObjectRegistry();
|
||||||
|
|
||||||
const machineManager = new MachineManager(
|
machineManager = new MachineManager(
|
||||||
scene,
|
scene,
|
||||||
registry,
|
registry,
|
||||||
props.sceneStore,
|
props.sceneStore,
|
||||||
@@ -435,7 +463,7 @@ export function CubeScene(props: {
|
|||||||
// - Select/deselects a cube in mode
|
// - Select/deselects a cube in mode
|
||||||
// - Creates a new cube in "create" mode
|
// - Creates a new cube in "create" mode
|
||||||
const onClick = (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (worldMode() === "create") {
|
if (ctx.worldMode() === "create") {
|
||||||
props
|
props
|
||||||
.onCreate()
|
.onCreate()
|
||||||
.then(({ id }) => {
|
.then(({ id }) => {
|
||||||
@@ -453,17 +481,16 @@ export function CubeScene(props: {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (actionBase) actionBase.visible = false;
|
if (actionBase) actionBase.visible = false;
|
||||||
|
|
||||||
setWorldMode("default");
|
ctx.setWorldMode("select");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (worldMode() === "move") {
|
if (ctx.worldMode() === "move") {
|
||||||
console.log("sanpped");
|
|
||||||
const currId = menuIntersection().at(0);
|
const currId = menuIntersection().at(0);
|
||||||
const pos = cursorPosition();
|
const pos = cursorPosition();
|
||||||
if (!currId || !pos) return;
|
if (!currId || !pos) return;
|
||||||
|
|
||||||
props.setMachinePos(currId, pos);
|
props.setMachinePos(currId, pos);
|
||||||
setWorldMode("select");
|
ctx.setWorldMode("select");
|
||||||
clearHighlight("move");
|
clearHighlight("move");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,18 +504,20 @@ export function CubeScene(props: {
|
|||||||
const intersects = raycaster.intersectObjects(
|
const intersects = raycaster.intersectObjects(
|
||||||
Array.from(machineManager.machines.values().map((m) => m.group)),
|
Array.from(machineManager.machines.values().map((m) => m.group)),
|
||||||
);
|
);
|
||||||
console.log("Intersects:", intersects);
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
console.log("Clicked on cube:", intersects);
|
const id = intersects.find((i) => i.object.userData?.id)?.object
|
||||||
const id = intersects[0].object.userData.id;
|
.userData.id;
|
||||||
|
|
||||||
if (worldMode() === "select") props.onSelect(new Set<string>([id]));
|
if (!id) return;
|
||||||
|
|
||||||
|
if (ctx.worldMode() === "select") props.onSelect(new Set<string>([id]));
|
||||||
|
|
||||||
|
console.log("Clicked on machine", id);
|
||||||
emitMachineClick(id); // notify subscribers
|
emitMachineClick(id); // notify subscribers
|
||||||
} else {
|
} else {
|
||||||
emitMachineClick(null);
|
emitMachineClick(null);
|
||||||
|
|
||||||
if (worldMode() === "select") props.onSelect(new Set<string>());
|
if (ctx.worldMode() === "select") props.onSelect(new Set<string>());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -520,24 +549,73 @@ export function CubeScene(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
const { machines, intersection } = intersectMachines(
|
||||||
|
e,
|
||||||
|
renderer,
|
||||||
|
camera,
|
||||||
|
machineManager,
|
||||||
|
raycaster,
|
||||||
|
);
|
||||||
|
if (e.button === 0) {
|
||||||
|
// Left button
|
||||||
|
|
||||||
|
if (ctx.worldMode() === "select" && machines.length) {
|
||||||
|
// Disable controls to avoid conflict
|
||||||
|
controls.enabled = false;
|
||||||
|
|
||||||
|
// Change cursor to grabbing
|
||||||
|
// LongPress, if not canceled, enters move mode
|
||||||
|
const cancelMove = setTimeout(() => {
|
||||||
|
setIsDragging(true);
|
||||||
|
const pos =
|
||||||
|
machineManager.machines.get(machines[0])?.group.position ||
|
||||||
|
new THREE.Vector3(0, 0, 0);
|
||||||
|
actionMachine?.position.set(pos.x, 0, pos.z);
|
||||||
|
// Set machine as flying
|
||||||
|
setHighlightGroups({ move: new Set(machines) });
|
||||||
|
|
||||||
|
ctx.setWorldMode("move");
|
||||||
|
renderLoop.requestRender();
|
||||||
|
}, 500);
|
||||||
|
setCancelMove(cancelMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const intersection = intersectMachines(
|
|
||||||
e,
|
|
||||||
renderer,
|
|
||||||
camera,
|
|
||||||
machineManager,
|
|
||||||
raycaster,
|
|
||||||
);
|
|
||||||
if (!intersection.length) return;
|
if (!intersection.length) return;
|
||||||
setMenuIntersection(intersection);
|
setMenuIntersection(machines);
|
||||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||||
setContextOpen(true);
|
setContextOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (cancelMove()) {
|
||||||
|
clearTimeout(cancelMove()!);
|
||||||
|
setCancelMove(undefined);
|
||||||
|
}
|
||||||
|
// Always re-enable controls
|
||||||
|
controls.enabled = true;
|
||||||
|
|
||||||
|
if (ctx.worldMode() === "move") {
|
||||||
|
// Set machine as not flying
|
||||||
|
const pos = actionMachine!.position.toArray();
|
||||||
|
props.setMachinePos(highlightGroups["move"].values().next().value!, [
|
||||||
|
pos[0], // x
|
||||||
|
pos[2], // z
|
||||||
|
]);
|
||||||
|
clearHighlight("move");
|
||||||
|
ctx.setWorldMode("select");
|
||||||
|
renderLoop.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
||||||
|
renderer.domElement.addEventListener("mouseup", handleMouseUp);
|
||||||
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
@@ -582,21 +660,55 @@ export function CubeScene(props: {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const snapToGrid = (point: THREE.Vector3) => {
|
||||||
|
if (!props.sceneStore) return;
|
||||||
|
// Snap to grid
|
||||||
|
const snapped = new THREE.Vector3(
|
||||||
|
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
||||||
|
0,
|
||||||
|
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip snapping if there's already a cube at this position
|
||||||
|
const positions = Object.entries(props.sceneStore());
|
||||||
|
const intersects = positions.some(
|
||||||
|
([_id, p]) => p.position[0] === snapped.x && p.position[1] === snapped.z,
|
||||||
|
);
|
||||||
|
const movingMachine = Array.from(highlightGroups["move"] || [])[0];
|
||||||
|
const startingPos = positions.find(([_id, p]) => _id === movingMachine);
|
||||||
|
if (startingPos) {
|
||||||
|
const isStartingPos =
|
||||||
|
snapped.x === startingPos[1].position[0] &&
|
||||||
|
snapped.z === startingPos[1].position[1];
|
||||||
|
// If Intersect any other machine and not the one being moved
|
||||||
|
if (!isStartingPos && intersects) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (intersects) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapped;
|
||||||
|
};
|
||||||
|
|
||||||
const onAddClick = (event: MouseEvent) => {
|
const onAddClick = (event: MouseEvent) => {
|
||||||
setPositionMode("grid");
|
setPositionMode("grid");
|
||||||
setWorldMode("create");
|
ctx.setWorldMode("create");
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
};
|
};
|
||||||
const onMouseMove = (event: MouseEvent) => {
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return;
|
||||||
if (!actionBase) return;
|
|
||||||
|
|
||||||
console.log("Mouse move in create/move mode");
|
const actionRepr =
|
||||||
|
ctx.worldMode() === "create" ? actionBase : actionMachine;
|
||||||
|
if (!actionRepr) return;
|
||||||
|
|
||||||
actionBase.visible = true;
|
actionRepr.visible = true;
|
||||||
(actionBase.material as THREE.MeshPhongMaterial).emissive.set(
|
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Calculate mouse position in normalized device coordinates
|
// Calculate mouse position in normalized device coordinates
|
||||||
// (-1 to +1) for both components
|
// (-1 to +1) for both components
|
||||||
@@ -611,41 +723,45 @@ export function CubeScene(props: {
|
|||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
const point = intersects[0].point;
|
const point = intersects[0].point;
|
||||||
|
|
||||||
// Snap to grid
|
const snapped = snapToGrid(point);
|
||||||
const snapped = new THREE.Vector3(
|
if (!snapped) return;
|
||||||
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
|
||||||
0,
|
|
||||||
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip snapping if there's already a cube at this position
|
|
||||||
if (props.sceneStore()) {
|
|
||||||
const positions = Object.values(props.sceneStore());
|
|
||||||
const intersects = positions.some(
|
|
||||||
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
|
|
||||||
);
|
|
||||||
if (intersects) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
|
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
|
||||||
Math.abs(actionBase.position.z - snapped.z) > 0.01
|
Math.abs(actionRepr.position.z - snapped.z) > 0.01
|
||||||
) {
|
) {
|
||||||
// Only request render if the position actually changed
|
// Only request render if the position actually changed
|
||||||
actionBase.position.set(snapped.x, 0, snapped.z);
|
actionRepr.position.set(snapped.x, 0, snapped.z);
|
||||||
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleMenuSelect = (mode: "move") => {
|
const handleMenuSelect = (mode: "move") => {
|
||||||
setWorldMode(mode);
|
ctx.setWorldMode(mode);
|
||||||
setHighlightGroups({ move: new Set(menuIntersection()) });
|
setHighlightGroups({ move: new Set(menuIntersection()) });
|
||||||
console.log("Menu selected, new World mode", worldMode());
|
|
||||||
|
// Find the position of the first selected machine
|
||||||
|
// Set the actionMachine position to that
|
||||||
|
const firstId = menuIntersection()[0];
|
||||||
|
if (firstId) {
|
||||||
|
const machine = machineManager.machines.get(firstId);
|
||||||
|
if (machine && actionMachine) {
|
||||||
|
actionMachine.position.set(
|
||||||
|
machine.group.position.x,
|
||||||
|
0,
|
||||||
|
machine.group.position.z,
|
||||||
|
);
|
||||||
|
setCursorPosition([machine.group.position.x, machine.group.position.z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(ctx.worldMode, (mode) => {
|
||||||
|
console.log("World mode changed to", mode);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const machinesQuery = useMachinesQuery(props.clanURI);
|
const machinesQuery = useMachinesQuery(props.clanURI);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -664,10 +780,10 @@ export function CubeScene(props: {
|
|||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"cubes-scene-container",
|
"cubes-scene-container",
|
||||||
worldMode() === "default" && "cursor-no-drop",
|
ctx.worldMode() === "default" && "cursor-no-drop",
|
||||||
worldMode() === "select" && "cursor-pointer",
|
ctx.worldMode() === "select" && "cursor-pointer",
|
||||||
worldMode() === "service" && "cursor-pointer",
|
ctx.worldMode() === "service" && "cursor-pointer",
|
||||||
worldMode() === "create" && "cursor-cell",
|
ctx.worldMode() === "create" && "cursor-cell",
|
||||||
isDragging() && "!cursor-grabbing",
|
isDragging() && "!cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
ref={(el) => (container = el)}
|
ref={(el) => (container = el)}
|
||||||
@@ -681,24 +797,25 @@ export function CubeScene(props: {
|
|||||||
description="Select machine"
|
description="Select machine"
|
||||||
name="Select"
|
name="Select"
|
||||||
icon="Cursor"
|
icon="Cursor"
|
||||||
onClick={() => setWorldMode("select")}
|
onClick={() => ctx.setWorldMode("select")}
|
||||||
selected={worldMode() === "select"}
|
selected={ctx.worldMode() === "select"}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
description="Create new machine"
|
description="Create new machine"
|
||||||
name="new-machine"
|
name="new-machine"
|
||||||
icon="NewMachine"
|
icon="NewMachine"
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
selected={worldMode() === "create"}
|
selected={ctx.worldMode() === "create"}
|
||||||
/>
|
/>
|
||||||
<Divider orientation="vertical" />
|
<Divider orientation="vertical" />
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
description="Add new Service"
|
description="Add new Service"
|
||||||
name="modules"
|
name="modules"
|
||||||
icon="Services"
|
icon="Services"
|
||||||
selected={worldMode() === "service"}
|
selected={ctx.worldMode() === "service"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWorldMode("service");
|
ctx.navigateToRoot();
|
||||||
|
ctx.setWorldMode("service");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|||||||
@@ -6,3 +6,35 @@ export const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>
|
|||||||
},
|
},
|
||||||
{} as Pick<T, K>,
|
{} as Pick<T, K>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const removeEmptyStrings = <T>(obj: T): T => {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => removeEmptyStrings(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === "object") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result: any = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
// eslint-disable-next-line no-prototype-builtins
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (value !== "") {
|
||||||
|
result[key] = removeEmptyStrings(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,13 +26,19 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
const resultData: Partial<ResultDataMap> = {
|
const resultData: Partial<ResultDataMap> = {
|
||||||
list_machines: {
|
list_machines: {
|
||||||
pandora: {
|
pandora: {
|
||||||
name: "pandora",
|
data: {
|
||||||
|
name: "pandora",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
enceladus: {
|
enceladus: {
|
||||||
name: "enceladus",
|
data: {
|
||||||
|
name: "enceladus",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dione: {
|
dione: {
|
||||||
name: "dione",
|
data: {
|
||||||
|
name: "dione",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface AddMachineProps {
|
|||||||
export interface AddMachineStoreType {
|
export interface AddMachineStoreType {
|
||||||
general: GeneralForm;
|
general: GeneralForm;
|
||||||
deploy: {
|
deploy: {
|
||||||
targetHost: string;
|
targetHost?: string;
|
||||||
};
|
};
|
||||||
tags: {
|
tags: {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -111,10 +111,7 @@ export const AddMachine = (props: AddMachineProps) => {
|
|||||||
return defaultClass;
|
return defaultClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (currentStep.id) {
|
return defaultClass;
|
||||||
default:
|
|
||||||
return defaultClass;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const StepProgress = (props: StepProgressProps) => {
|
|||||||
when={store.error}
|
when={store.error}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Loader class="size-8" />
|
<Loader size="l" />
|
||||||
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||||
{store.general?.name} is being created
|
{store.general?.name} is being created
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { BackButton, StepLayout } from "@/src/workflows/Steps";
|
import { BackButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
|
import {
|
||||||
|
createForm,
|
||||||
|
setValue,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
import {
|
import {
|
||||||
AddMachineSteps,
|
AddMachineSteps,
|
||||||
AddMachineStoreType,
|
AddMachineStoreType,
|
||||||
@@ -11,6 +16,7 @@ import { MachineTags } from "@/src/components/Form/MachineTags";
|
|||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
import { useClanURI } from "@/src/hooks/clan";
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
import { removeEmptyStrings } from "@/src/util";
|
||||||
|
|
||||||
const TagsSchema = v.object({
|
const TagsSchema = v.object({
|
||||||
tags: v.array(v.string()),
|
tags: v.array(v.string()),
|
||||||
@@ -36,16 +42,20 @@ export const StepTags = (props: { onDone: () => void }) => {
|
|||||||
...values,
|
...values,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const machine = removeEmptyStrings({
|
||||||
|
...store.general,
|
||||||
|
...store.tags,
|
||||||
|
deploy: store.deploy,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("machine", machine);
|
||||||
|
|
||||||
const call = apiClient.fetch("create_machine", {
|
const call = apiClient.fetch("create_machine", {
|
||||||
opts: {
|
opts: {
|
||||||
clan_dir: {
|
clan_dir: {
|
||||||
identifier: clanURI,
|
identifier: clanURI,
|
||||||
},
|
},
|
||||||
machine: {
|
machine,
|
||||||
...store.general,
|
|
||||||
...store.tags,
|
|
||||||
deploy: store.deploy,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,9 +88,12 @@ export const StepTags = (props: { onDone: () => void }) => {
|
|||||||
{...field}
|
{...field}
|
||||||
required
|
required
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
defaultValue={field.value}
|
defaultValue={field.value || []}
|
||||||
defaultOptions={[]}
|
defaultOptions={[]}
|
||||||
input={input}
|
onChange={(newVal) => {
|
||||||
|
// Workaround for now, until we manage to use native events
|
||||||
|
setValue(formStore, field.name, newVal);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
{
|
{
|
||||||
name: "gritty.foo",
|
name: "gritty.foo",
|
||||||
description: "Name of the gritty",
|
description: "Name of the gritty",
|
||||||
prompt_type: "line",
|
prompt_type: "hidden",
|
||||||
display: {
|
display: {
|
||||||
helperText: null,
|
helperText: null,
|
||||||
label: "(2) Password",
|
label: "(2) Password",
|
||||||
@@ -113,7 +113,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
{
|
{
|
||||||
name: "gritty.foo",
|
name: "gritty.foo",
|
||||||
description: "Name of the gritty",
|
description: "Name of the gritty",
|
||||||
prompt_type: "line",
|
prompt_type: "hidden",
|
||||||
display: {
|
display: {
|
||||||
helperText: null,
|
helperText: null,
|
||||||
label: "(5) Password",
|
label: "(5) Password",
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ export interface InstallStoreType {
|
|||||||
progress: ApiCall<"run_machine_flash">;
|
progress: ApiCall<"run_machine_flash">;
|
||||||
};
|
};
|
||||||
install: {
|
install: {
|
||||||
targetHost: string;
|
targetHost?: string;
|
||||||
port?: string;
|
port?: string;
|
||||||
|
password?: string;
|
||||||
machineName: string;
|
machineName: string;
|
||||||
mainDisk: string;
|
mainDisk?: string;
|
||||||
// ...TODO Vars
|
// ...TODO Vars
|
||||||
progress: ApiCall<"run_machine_install">;
|
progress: ApiCall<"run_machine_install" | "run_machine_update">;
|
||||||
promptValues: PromptValues;
|
promptValues: PromptValues;
|
||||||
prepareStep: "disk" | "generators" | "install";
|
prepareStep: "disk" | "generators" | "install";
|
||||||
};
|
};
|
||||||
@@ -106,22 +107,23 @@ export const InstallModal = (props: InstallModalProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
props.onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepperProvider stepper={stepper}>
|
<StepperProvider stepper={stepper}>
|
||||||
<Modal
|
<Modal
|
||||||
class={cx("w-screen", sizeClasses())}
|
class={cx("w-screen", sizeClasses())}
|
||||||
title="Install machine"
|
title="Install machine"
|
||||||
onClose={() => {
|
onClose={onClose}
|
||||||
console.log("Install modal closed");
|
|
||||||
props.onClose?.();
|
|
||||||
}}
|
|
||||||
open={props.open}
|
open={props.open}
|
||||||
// @ts-expect-error some steps might not have
|
// @ts-expect-error some steps might not have
|
||||||
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
||||||
// @ts-expect-error some steps might not have
|
// @ts-expect-error some steps might not have
|
||||||
disablePadding={stepper.currentStep()?.isSplash}
|
disablePadding={stepper.currentStep()?.isSplash}
|
||||||
>
|
>
|
||||||
<InstallStepper onDone={() => props.onClose} />
|
<InstallStepper onDone={onClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</StepperProvider>
|
</StepperProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
MemoryRouter,
|
||||||
|
RouteDefinition,
|
||||||
|
} from "@solidjs/router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
|
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
|
||||||
|
import {
|
||||||
|
ApiCall,
|
||||||
|
OperationNames,
|
||||||
|
OperationResponse,
|
||||||
|
SuccessQuery,
|
||||||
|
} from "@/src/hooks/api";
|
||||||
|
import { UpdateModal } from "./UpdateMachine";
|
||||||
|
|
||||||
|
type ResultDataMap = {
|
||||||
|
[K in OperationNames]: SuccessQuery<K>["data"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||||
|
name: K,
|
||||||
|
_args: unknown,
|
||||||
|
): ApiCall<K> => {
|
||||||
|
// TODO: Make this configurable for every story
|
||||||
|
const resultData: Partial<ResultDataMap> = {
|
||||||
|
get_generators: [
|
||||||
|
{
|
||||||
|
name: "funny.gritty",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
name: "gritty.name",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(1) Name",
|
||||||
|
group: "User",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gritty.foo",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(2) Password",
|
||||||
|
group: "Root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gritty.bar",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(3) Gritty",
|
||||||
|
group: "Root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "funny.dodo",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
name: "gritty.name",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(4) Name",
|
||||||
|
group: "User",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gritty.foo",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(5) Password",
|
||||||
|
group: "Lonely",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gritty.bar",
|
||||||
|
description: "Name of the gritty",
|
||||||
|
prompt_type: "line",
|
||||||
|
display: {
|
||||||
|
helperText: null,
|
||||||
|
label: "(6) Batty",
|
||||||
|
group: "Root",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run_generators: null,
|
||||||
|
run_machine_update: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: "mock",
|
||||||
|
cancel: () => Promise.resolve(),
|
||||||
|
result: new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const status = name === "run_machine_update" ? "error" : "success";
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
op_key: "1",
|
||||||
|
status: status,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: "Mock error message",
|
||||||
|
description:
|
||||||
|
"This is a more detailed description of the mock error.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
data: resultData[name],
|
||||||
|
} as OperationResponse<K>);
|
||||||
|
}, 1500);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof UpdateModal> = {
|
||||||
|
title: "workflows/update",
|
||||||
|
component: UpdateModal,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext) => {
|
||||||
|
const Routes: RouteDefinition[] = [
|
||||||
|
{
|
||||||
|
path: "/clans/:clanURI",
|
||||||
|
component: () => (
|
||||||
|
<div class="w-[600px]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const history = createMemoryHistory();
|
||||||
|
history.set({ value: "/clans/dGVzdA==", replace: true });
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiClientProvider client={{ fetch: mockFetcher }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter
|
||||||
|
root={(props) => {
|
||||||
|
console.debug("Rendering MemoryRouter root with props:", props);
|
||||||
|
return props.children;
|
||||||
|
}}
|
||||||
|
history={history}
|
||||||
|
>
|
||||||
|
{Routes}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiClientProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof UpdateModal>;
|
||||||
|
|
||||||
|
export const Init: Story = {
|
||||||
|
description: "Welcome step for the update workflow",
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
machineName: "Jon",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Address: Story = {
|
||||||
|
description: "Welcome step for the update workflow",
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
machineName: "Jon",
|
||||||
|
initialStep: "update:address",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const UpdateProgress: Story = {
|
||||||
|
description: "Welcome step for the update workflow",
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
machineName: "Jon",
|
||||||
|
initialStep: "update:progress",
|
||||||
|
},
|
||||||
|
};
|
||||||
304
pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx
Normal file
304
pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
|
import {
|
||||||
|
createStepper,
|
||||||
|
getStepStore,
|
||||||
|
StepperProvider,
|
||||||
|
useStepper,
|
||||||
|
} from "@/src/hooks/stepper";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { ConfigureAddress, ConfigureData } from "./steps/installSteps";
|
||||||
|
|
||||||
|
import cx from "classnames";
|
||||||
|
import { InstallStoreType } from "./InstallMachine";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import Icon from "@/src/components/Icon/Icon";
|
||||||
|
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
|
||||||
|
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
import { AlertProps } from "@/src/components/Alert/Alert";
|
||||||
|
|
||||||
|
// TODO: Deduplicate
|
||||||
|
interface UpdateStepperProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
const UpdateStepper = (props: UpdateStepperProps) => {
|
||||||
|
const stepSignal = useStepper<UpdateSteps>();
|
||||||
|
|
||||||
|
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [alert, setAlert] = createSignal<AlertProps>();
|
||||||
|
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const client = useApiClient();
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
console.log("Starting update for", store.install.machineName);
|
||||||
|
|
||||||
|
if (!store.install.targetHost) {
|
||||||
|
console.error("No target host specified, API requires it");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = store.install.port
|
||||||
|
? parseInt(store.install.port, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const call = client.fetch("run_machine_update", {
|
||||||
|
machine: {
|
||||||
|
flake: { identifier: clanURI },
|
||||||
|
name: store.install.machineName,
|
||||||
|
},
|
||||||
|
build_host: null,
|
||||||
|
target_host: {
|
||||||
|
address: store.install.targetHost,
|
||||||
|
port,
|
||||||
|
password: store.install.password,
|
||||||
|
ssh_options: {
|
||||||
|
StrictHostKeyChecking: "no",
|
||||||
|
UserKnownHostsFile: "/dev/null",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// For cancel
|
||||||
|
set("install", "progress", call);
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
console.error("Update failed", result.errors);
|
||||||
|
setAlert(() => ({
|
||||||
|
type: "error",
|
||||||
|
title: "Update failed",
|
||||||
|
description: result.errors[0].message,
|
||||||
|
}));
|
||||||
|
stepSignal.previous();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === "success") {
|
||||||
|
stepSignal.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={stepSignal.currentStep().content}
|
||||||
|
onDone={props.onDone}
|
||||||
|
next="update"
|
||||||
|
stepFinished={handleUpdate}
|
||||||
|
alert={alert()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateModalProps {
|
||||||
|
machineName: string;
|
||||||
|
open: boolean;
|
||||||
|
initialStep?: UpdateSteps[number]["id"];
|
||||||
|
mount?: Node;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateHeader = (props: { machineName: string }) => {
|
||||||
|
return (
|
||||||
|
<Typography hierarchy="label" size="default">
|
||||||
|
Update: {props.machineName}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateTopic = [
|
||||||
|
"generators",
|
||||||
|
"upload-secrets",
|
||||||
|
"nixos-anywhere",
|
||||||
|
"formatting",
|
||||||
|
"rebooting",
|
||||||
|
"installing",
|
||||||
|
][number];
|
||||||
|
|
||||||
|
const UpdateProgress = () => {
|
||||||
|
const stepSignal = useStepper<UpdateSteps>();
|
||||||
|
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
const progress = store.install.progress;
|
||||||
|
if (progress) {
|
||||||
|
await progress.cancel();
|
||||||
|
}
|
||||||
|
stepSignal.previous();
|
||||||
|
};
|
||||||
|
const updateState =
|
||||||
|
useNotifyOrigin<ProcessMessage<unknown, UpdateTopic>>("run_machine_update");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative flex size-full flex-col items-center justify-end bg-inv-4">
|
||||||
|
<img
|
||||||
|
src="/logos/usb-stick-min.png"
|
||||||
|
alt="usb logo"
|
||||||
|
class="absolute top-2 z-0"
|
||||||
|
/>
|
||||||
|
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 fg-inv-1">
|
||||||
|
<Typography
|
||||||
|
hierarchy="title"
|
||||||
|
size="default"
|
||||||
|
weight="bold"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Machine is being updated
|
||||||
|
</Typography>
|
||||||
|
<LoadingBar />
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="default"
|
||||||
|
class=""
|
||||||
|
color="secondary"
|
||||||
|
inverted
|
||||||
|
>
|
||||||
|
Update {updateState()?.topic}...
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
class="mt-3 w-fit"
|
||||||
|
size="s"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UpdateDoneProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
const UpdateDone = (props: UpdateDoneProps) => {
|
||||||
|
const stepSignal = useStepper<UpdateSteps>();
|
||||||
|
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
|
||||||
|
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
|
||||||
|
<div class="rounded-full bg-semantic-success-4">
|
||||||
|
<Icon icon="Checkmark" class="size-9" />
|
||||||
|
</div>
|
||||||
|
<Typography
|
||||||
|
hierarchy="title"
|
||||||
|
size="default"
|
||||||
|
weight="bold"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Machine update finished!
|
||||||
|
</Typography>
|
||||||
|
<div class="mt-3 flex w-full justify-center">
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
endIcon="Close"
|
||||||
|
size="s"
|
||||||
|
onClick={() => props.onDone()}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
id: "update:data",
|
||||||
|
title: UpdateHeader,
|
||||||
|
content: ConfigureData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "update:address",
|
||||||
|
title: UpdateHeader,
|
||||||
|
content: ConfigureAddress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "update:progress",
|
||||||
|
content: UpdateProgress,
|
||||||
|
isSplash: true,
|
||||||
|
class: "max-w-[30rem] h-[18rem]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "update:done",
|
||||||
|
content: UpdateDone,
|
||||||
|
isSplash: true,
|
||||||
|
class: "max-w-[30rem] h-[18rem]",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type UpdateSteps = typeof steps;
|
||||||
|
export type PromptValues = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
export const UpdateModal = (props: UpdateModalProps) => {
|
||||||
|
const stepper = createStepper(
|
||||||
|
{
|
||||||
|
steps,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialStep: props.initialStep || "update:data",
|
||||||
|
initialStoreData: {
|
||||||
|
install: { machineName: props.machineName },
|
||||||
|
} as Partial<InstallStoreType>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const MetaHeader = () => {
|
||||||
|
// @ts-expect-error some steps might not provide a title
|
||||||
|
const HeaderComponent = () => stepper.currentStep()?.title;
|
||||||
|
return (
|
||||||
|
<Show when={HeaderComponent()}>
|
||||||
|
{(C) => <Dynamic component={C()} machineName={props.machineName} />}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const [store, set] = getStepStore<InstallStoreType>(stepper);
|
||||||
|
|
||||||
|
set("install", { machineName: props.machineName });
|
||||||
|
|
||||||
|
// allows each step to adjust the size of the modal
|
||||||
|
const sizeClasses = () => {
|
||||||
|
const defaultClass = "max-w-3xl h-[30rem]";
|
||||||
|
|
||||||
|
const currentStep = stepper.currentStep();
|
||||||
|
if (!currentStep) {
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentStep.id) {
|
||||||
|
case "update:progress":
|
||||||
|
case "update:done":
|
||||||
|
return currentStep.class;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
props.onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperProvider stepper={stepper}>
|
||||||
|
<Modal
|
||||||
|
class={cx("w-screen", sizeClasses())}
|
||||||
|
title="Update machine"
|
||||||
|
onClose={onClose}
|
||||||
|
open={props.open}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
disablePadding={stepper.currentStep()?.isSplash}
|
||||||
|
>
|
||||||
|
<UpdateStepper onDone={onClose} />
|
||||||
|
</Modal>
|
||||||
|
</StepperProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
PromptValues,
|
PromptValues,
|
||||||
} from "../InstallMachine";
|
} from "../InstallMachine";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { Alert } from "@/src/components/Alert/Alert";
|
import { Alert, AlertProps } from "@/src/components/Alert/Alert";
|
||||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||||
import { Divider } from "@/src/components/Divider/Divider";
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
import { Orienter } from "@/src/components/Form/Orienter";
|
import { Orienter } from "@/src/components/Form/Orienter";
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
import { useClanURI } from "@/src/hooks/clan";
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
|
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
|
||||||
|
import { Loader } from "@/src/components/Loader/Loader";
|
||||||
|
import { Button as KButton } from "@kobalte/core/button";
|
||||||
|
|
||||||
export const InstallHeader = (props: { machineName: string }) => {
|
export const InstallHeader = (props: { machineName: string }) => {
|
||||||
return (
|
return (
|
||||||
@@ -54,11 +56,16 @@ const ConfigureAdressSchema = v.object({
|
|||||||
v.transform((val) => (val === "" ? undefined : val)),
|
v.transform((val) => (val === "" ? undefined : val)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
password: v.optional(v.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
|
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
|
||||||
|
|
||||||
const ConfigureAddress = () => {
|
export const ConfigureAddress = (props: {
|
||||||
|
next?: string;
|
||||||
|
stepFinished: () => void;
|
||||||
|
alert?: AlertProps;
|
||||||
|
}) => {
|
||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
@@ -84,10 +91,11 @@ const ConfigureAddress = () => {
|
|||||||
...s,
|
...s,
|
||||||
targetHost: values.targetHost,
|
targetHost: values.targetHost,
|
||||||
port: values.port,
|
port: values.port,
|
||||||
|
password: values.password,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Here you would typically trigger the ISO creation process
|
|
||||||
stepSignal.next();
|
stepSignal.next();
|
||||||
|
props.stepFinished?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryReachable = async () => {
|
const tryReachable = async () => {
|
||||||
@@ -98,12 +106,14 @@ const ConfigureAddress = () => {
|
|||||||
|
|
||||||
const portValue = getValue(formStore, "port");
|
const portValue = getValue(formStore, "port");
|
||||||
const port = portValue ? parseInt(portValue, 10) : undefined;
|
const port = portValue ? parseInt(portValue, 10) : undefined;
|
||||||
|
const password = getValue(formStore, "password") || undefined;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const call = client.fetch("check_machine_ssh_login", {
|
const call = client.fetch("check_machine_ssh_login", {
|
||||||
remote: {
|
remote: {
|
||||||
address,
|
address,
|
||||||
...(port && { port }),
|
...(port && { port }),
|
||||||
|
password: password,
|
||||||
ssh_options: {
|
ssh_options: {
|
||||||
StrictHostKeyChecking: "no",
|
StrictHostKeyChecking: "no",
|
||||||
UserKnownHostsFile: "/dev/null",
|
UserKnownHostsFile: "/dev/null",
|
||||||
@@ -124,13 +134,14 @@ const ConfigureAddress = () => {
|
|||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<Show when={props.alert}>{(alert) => <Alert {...alert()} />}</Show>
|
||||||
<Fieldset>
|
<Fieldset>
|
||||||
<Field name="targetHost">
|
<Field name="targetHost">
|
||||||
{(field, props) => (
|
{(field, props) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...field}
|
||||||
label="IP Address"
|
label="IP Address"
|
||||||
description="Hostname of the installation target"
|
description="Hostname of the machine"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
required
|
required
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -163,6 +174,24 @@ const ConfigureAddress = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field name="password">
|
||||||
|
{(field, props) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
label="Password"
|
||||||
|
description="SSH password (optional)"
|
||||||
|
value={field.value}
|
||||||
|
orientation="horizontal"
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "port") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
input={{
|
||||||
|
...props,
|
||||||
|
type: "password",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -175,7 +204,9 @@ const ConfigureAddress = () => {
|
|||||||
!isReachable() ||
|
!isReachable() ||
|
||||||
isReachable() !== getValue(formStore, "targetHost")
|
isReachable() !== getValue(formStore, "targetHost")
|
||||||
}
|
}
|
||||||
fallback={<NextButton type="submit">Next</NextButton>}
|
fallback={
|
||||||
|
<NextButton type="submit">{props.next || "next"}</NextButton>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
endIcon="ArrowRight"
|
endIcon="ArrowRight"
|
||||||
@@ -212,6 +243,14 @@ const CheckHardware = () => {
|
|||||||
createSignal(false);
|
createSignal(false);
|
||||||
|
|
||||||
const handleUpdateSummary = async () => {
|
const handleUpdateSummary = async () => {
|
||||||
|
if (!store.install.targetHost) {
|
||||||
|
console.error(
|
||||||
|
"Target host not set, this is required for updating hardware report",
|
||||||
|
);
|
||||||
|
setUpdatingHardwareReport(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUpdatingHardwareReport(true);
|
setUpdatingHardwareReport(true);
|
||||||
|
|
||||||
const port = store.install.port
|
const port = store.install.port
|
||||||
@@ -223,7 +262,8 @@ const CheckHardware = () => {
|
|||||||
const call = client.fetch("run_machine_hardware_info", {
|
const call = client.fetch("run_machine_hardware_info", {
|
||||||
target_host: {
|
target_host: {
|
||||||
address: store.install.targetHost,
|
address: store.install.targetHost,
|
||||||
...(port && { port }),
|
port,
|
||||||
|
password: store.install.password,
|
||||||
ssh_options: {
|
ssh_options: {
|
||||||
StrictHostKeyChecking: "no",
|
StrictHostKeyChecking: "no",
|
||||||
UserKnownHostsFile: "/dev/null",
|
UserKnownHostsFile: "/dev/null",
|
||||||
@@ -386,7 +426,7 @@ const ConfigureDisk = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ConfigureData = () => {
|
export const ConfigureData = () => {
|
||||||
const stepSignal = useStepper<InstallSteps>();
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
|
||||||
|
|
||||||
@@ -398,7 +438,22 @@ const ConfigureData = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={generatorsQuery.isLoading}>
|
<Show when={generatorsQuery.isLoading}>
|
||||||
Checking credentials & data...
|
<div class="relative flex w-full flex-col items-center justify-end ">
|
||||||
|
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 pt-4">
|
||||||
|
<Loader />
|
||||||
|
<Typography
|
||||||
|
hierarchy="title"
|
||||||
|
size="default"
|
||||||
|
weight="bold"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Credentials & Data
|
||||||
|
</Typography>
|
||||||
|
<Typography hierarchy="label" size="default" color="secondary">
|
||||||
|
Loading Machine Generators ...
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={generatorsQuery.data}>
|
<Show when={generatorsQuery.data}>
|
||||||
{(generators) => <PromptsFields generators={generators()} />}
|
{(generators) => <PromptsFields generators={generators()} />}
|
||||||
@@ -500,7 +555,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -512,35 +567,64 @@ const PromptsFields = (props: PromptsFieldsProps) => {
|
|||||||
<Field
|
<Field
|
||||||
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
|
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
|
||||||
>
|
>
|
||||||
{(f, props) => (
|
{(f, props) => {
|
||||||
<TextInput
|
const defaultInputType =
|
||||||
{...f}
|
fieldInfo.prompt.prompt_type.includes("hidden")
|
||||||
label={
|
? "password"
|
||||||
fieldInfo.prompt.display?.label ||
|
: "text";
|
||||||
fieldInfo.prompt.name
|
|
||||||
}
|
const [inputType, setInputType] =
|
||||||
description={fieldInfo.prompt.description}
|
createSignal(defaultInputType);
|
||||||
value={f.value || fieldInfo.value || ""}
|
|
||||||
required={fieldInfo.prompt.display?.required}
|
return (
|
||||||
orientation="horizontal"
|
<TextInput
|
||||||
validationState={
|
{...f}
|
||||||
getError(
|
label={
|
||||||
formStore,
|
fieldInfo.prompt.display?.label ||
|
||||||
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
|
fieldInfo.prompt.name
|
||||||
)
|
}
|
||||||
? "invalid"
|
endComponent={(local) => (
|
||||||
: "valid"
|
<Show when={defaultInputType === "password"}>
|
||||||
}
|
<KButton
|
||||||
input={{
|
onClick={() => {
|
||||||
type: fieldInfo.prompt.prompt_type.includes(
|
setInputType((type) =>
|
||||||
"hidden",
|
type === "password"
|
||||||
)
|
? "text"
|
||||||
? "password"
|
: "password",
|
||||||
: "text",
|
);
|
||||||
...props,
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
<Icon
|
||||||
)}
|
icon={
|
||||||
|
inputType() == "password"
|
||||||
|
? "EyeClose"
|
||||||
|
: "EyeOpen"
|
||||||
|
}
|
||||||
|
color="quaternary"
|
||||||
|
inverted={local.inverted}
|
||||||
|
/>
|
||||||
|
</KButton>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
description={fieldInfo.prompt.description}
|
||||||
|
value={f.value || fieldInfo.value || ""}
|
||||||
|
required={fieldInfo.prompt.display?.required}
|
||||||
|
orientation="horizontal"
|
||||||
|
validationState={
|
||||||
|
getError(
|
||||||
|
formStore,
|
||||||
|
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
|
||||||
|
)
|
||||||
|
? "invalid"
|
||||||
|
: "valid"
|
||||||
|
}
|
||||||
|
input={{
|
||||||
|
type: inputType(),
|
||||||
|
...props,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -560,7 +644,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Display = (props: { value: string; label: string }) => {
|
const Display = (props: { value?: string; label: string }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography hierarchy="label" size="xs" color="primary" weight="bold">
|
<Typography hierarchy="label" size="xs" color="primary" weight="bold">
|
||||||
@@ -583,7 +667,15 @@ const InstallSummary = () => {
|
|||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
// Here you would typically trigger the installation process
|
// Here you would typically trigger the installation process
|
||||||
console.log("Installation started");
|
console.log("Installation started");
|
||||||
|
if (!store.install.mainDisk) {
|
||||||
|
console.error("Main disk not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.install.targetHost) {
|
||||||
|
console.error("Target host not set, this is required for installing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
stepSignal.setActiveStep("install:progress");
|
stepSignal.setActiveStep("install:progress");
|
||||||
|
|
||||||
const setDisk = client.fetch("set_machine_disk_schema", {
|
const setDisk = client.fetch("set_machine_disk_schema", {
|
||||||
@@ -649,7 +741,8 @@ const InstallSummary = () => {
|
|||||||
},
|
},
|
||||||
target_host: {
|
target_host: {
|
||||||
address: store.install.targetHost,
|
address: store.install.targetHost,
|
||||||
...(port && { port }),
|
port,
|
||||||
|
password: store.install.password,
|
||||||
ssh_options: {
|
ssh_options: {
|
||||||
StrictHostKeyChecking: "no",
|
StrictHostKeyChecking: "no",
|
||||||
UserKnownHostsFile: "/dev/null",
|
UserKnownHostsFile: "/dev/null",
|
||||||
@@ -693,7 +786,7 @@ const InstallSummary = () => {
|
|||||||
</Orienter>
|
</Orienter>
|
||||||
<Divider orientation="horizontal" />
|
<Divider orientation="horizontal" />
|
||||||
<Orienter orientation="horizontal">
|
<Orienter orientation="horizontal">
|
||||||
<Display label="Main Disk" value={store.install.mainDisk} />
|
<Display label="Main Disk" value={store.install?.mainDisk} />
|
||||||
</Orienter>
|
</Orienter>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal file
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Search } from "@/src/components/Search/Search";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { buildServicePath, useClanURI } from "@/src/hooks/clan";
|
||||||
|
import { useServiceInstances, useServiceModules } from "@/src/hooks/queries";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
import { createEffect, createSignal, Show } from "solid-js";
|
||||||
|
import { Module } from "./models";
|
||||||
|
import Icon from "@/src/components/Icon/Icon";
|
||||||
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
|
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||||
|
|
||||||
|
interface FlyoutProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
export const SelectService = (props: FlyoutProps) => {
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const serviceModulesQuery = useServiceModules(clanURI);
|
||||||
|
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||||
|
|
||||||
|
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
||||||
|
setModuleOptions(
|
||||||
|
serviceModulesQuery.data.modules.map((currService) => ({
|
||||||
|
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
||||||
|
label: currService.usage_ref.name,
|
||||||
|
raw: currService,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (module: Module | null) => {
|
||||||
|
if (!module) return;
|
||||||
|
|
||||||
|
const serviceURL = buildServicePath({
|
||||||
|
clanURI,
|
||||||
|
id: module.raw.instance_refs[0] || module.raw.usage_ref.name,
|
||||||
|
module: {
|
||||||
|
name: module.raw.usage_ref.name,
|
||||||
|
input: module.raw.usage_ref.input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
navigate(serviceURL);
|
||||||
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
let ref: HTMLDivElement;
|
||||||
|
|
||||||
|
useClickOutside(
|
||||||
|
() => ref,
|
||||||
|
() => {
|
||||||
|
props.onClose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(e) => (ref = e)}
|
||||||
|
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
||||||
|
>
|
||||||
|
<div class="w-[30rem]">
|
||||||
|
<Search<Module>
|
||||||
|
loading={
|
||||||
|
serviceModulesQuery.isLoading || serviceInstancesQuery.isLoading
|
||||||
|
}
|
||||||
|
height="13rem"
|
||||||
|
onChange={handleChange}
|
||||||
|
options={moduleOptions()}
|
||||||
|
renderItem={(item, opts) => {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
|
||||||
|
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||||
|
<Icon icon="Code" />
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<Combobox.ItemLabel class="flex gap-1.5">
|
||||||
|
<Show when={item.raw.instance_refs.length > 0}>
|
||||||
|
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
||||||
|
<Typography hierarchy="label" weight="bold" size="xxs">
|
||||||
|
Added
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="s"
|
||||||
|
weight="medium"
|
||||||
|
inverted
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Combobox.ItemLabel>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xxs"
|
||||||
|
weight="normal"
|
||||||
|
color="quaternary"
|
||||||
|
inverted
|
||||||
|
class="flex justify-between"
|
||||||
|
>
|
||||||
|
<span class="inline-block max-w-80 truncate align-middle">
|
||||||
|
{item.raw.info.manifest.description}
|
||||||
|
</span>
|
||||||
|
<span class="inline-block max-w-32 truncate align-middle">
|
||||||
|
<Show when={!item.raw.native} fallback="by clan-core">
|
||||||
|
by {item.raw.usage_ref.input}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -62,20 +62,28 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
},
|
},
|
||||||
list_machines: {
|
list_machines: {
|
||||||
jon: {
|
jon: {
|
||||||
name: "jon",
|
data: {
|
||||||
tags: ["all", "nixos", "tag1"],
|
name: "jon",
|
||||||
|
tags: ["all", "nixos", "tag1"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sara: {
|
sara: {
|
||||||
name: "sara",
|
data: {
|
||||||
tags: ["all", "darwin", "tag2"],
|
name: "sara",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
kyra: {
|
kyra: {
|
||||||
name: "kyra",
|
data: {
|
||||||
tags: ["all", "darwin", "tag2"],
|
name: "kyra",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
leila: {
|
leila: {
|
||||||
name: "leila",
|
data: {
|
||||||
tags: ["all", "darwin", "tag2"],
|
name: "leila",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list_tags: {
|
list_tags: {
|
||||||
@@ -152,6 +160,9 @@ export const SelectRoleMembers: Story = {
|
|||||||
handleSubmit={(instance) => {
|
handleSubmit={(instance) => {
|
||||||
console.log("Submitted instance:", instance);
|
console.log("Submitted instance:", instance);
|
||||||
}}
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("Closed");
|
||||||
|
}}
|
||||||
initialStep="select:members"
|
initialStep="select:members"
|
||||||
initialStore={{
|
initialStore={{
|
||||||
currentRole: "peer",
|
currentRole: "peer",
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import {
|
|||||||
StepperProvider,
|
StepperProvider,
|
||||||
useStepper,
|
useStepper,
|
||||||
} from "@/src/hooks/stepper";
|
} from "@/src/hooks/stepper";
|
||||||
import { useClanURI } from "@/src/hooks/clan";
|
import { useClanURI, useServiceParams } from "@/src/hooks/clan";
|
||||||
import {
|
import {
|
||||||
MachinesQuery,
|
MachinesQuery,
|
||||||
ServiceModules,
|
|
||||||
TagsQuery,
|
TagsQuery,
|
||||||
useMachinesQuery,
|
useMachinesQuery,
|
||||||
useServiceInstances,
|
useServiceInstances,
|
||||||
@@ -18,18 +17,15 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
Show,
|
Show,
|
||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
|
For,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { Search } from "@/src/components/Search/Search";
|
|
||||||
import Icon from "@/src/components/Icon/Icon";
|
import Icon from "@/src/components/Icon/Icon";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { TagSelect } from "@/src/components/Search/TagSelect";
|
|
||||||
import { Tag } from "@/src/components/Tag/Tag";
|
|
||||||
import { createForm, FieldValues } from "@modular-forms/solid";
|
import { createForm, FieldValues } from "@modular-forms/solid";
|
||||||
import styles from "./Service.module.css";
|
import styles from "./Service.module.css";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
@@ -40,152 +36,16 @@ import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
|
|||||||
import { useMachineClick } from "@/src/scene/cubes";
|
import { useMachineClick } from "@/src/scene/cubes";
|
||||||
import {
|
import {
|
||||||
clearAllHighlights,
|
clearAllHighlights,
|
||||||
highlightGroups,
|
|
||||||
setHighlightGroups,
|
setHighlightGroups,
|
||||||
} from "@/src/scene/highlightStore";
|
} from "@/src/scene/highlightStore";
|
||||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
import {
|
||||||
|
getRoleMembers,
|
||||||
type ModuleItem = ServiceModules["modules"][number];
|
RoleType,
|
||||||
|
ServiceStoreType,
|
||||||
interface Module {
|
SubmitServiceHandler,
|
||||||
value: string;
|
} from "./models";
|
||||||
label: string;
|
import { TagSelect } from "@/src/components/Search/TagSelect";
|
||||||
raw: ModuleItem;
|
import { Tag } from "@/src/components/Tag/Tag";
|
||||||
}
|
|
||||||
|
|
||||||
const SelectService = () => {
|
|
||||||
const clanURI = useClanURI();
|
|
||||||
const stepper = useStepper<ServiceSteps>();
|
|
||||||
|
|
||||||
const serviceModulesQuery = useServiceModules(clanURI);
|
|
||||||
const serviceInstancesQuery = useServiceInstances(clanURI);
|
|
||||||
const machinesQuery = useMachinesQuery(clanURI);
|
|
||||||
|
|
||||||
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
|
|
||||||
createEffect(() => {
|
|
||||||
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
|
||||||
setModuleOptions(
|
|
||||||
serviceModulesQuery.data.modules.map((currService) => ({
|
|
||||||
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
|
||||||
label: currService.usage_ref.name,
|
|
||||||
raw: currService,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Search<Module>
|
|
||||||
loading={serviceModulesQuery.isLoading}
|
|
||||||
height="13rem"
|
|
||||||
onChange={(module) => {
|
|
||||||
if (!module) return;
|
|
||||||
|
|
||||||
set("module", {
|
|
||||||
name: module.raw.usage_ref.name,
|
|
||||||
input: module.raw.usage_ref.input,
|
|
||||||
raw: module.raw,
|
|
||||||
});
|
|
||||||
// TODO: Ideally we need to ask
|
|
||||||
// - create new
|
|
||||||
// - update existing (and select which one)
|
|
||||||
|
|
||||||
// For now:
|
|
||||||
// Create a new instance, if there are no instances yet
|
|
||||||
// Update the first instance, if there is one
|
|
||||||
if (module.raw.instance_refs.length === 0) {
|
|
||||||
set("action", "create");
|
|
||||||
} else {
|
|
||||||
if (!serviceInstancesQuery.data) return;
|
|
||||||
if (!machinesQuery.data) return;
|
|
||||||
set("action", "update");
|
|
||||||
|
|
||||||
const instanceName = module.raw.instance_refs[0];
|
|
||||||
const instance = serviceInstancesQuery.data[instanceName];
|
|
||||||
console.log("Editing existing instance", module);
|
|
||||||
|
|
||||||
for (const role of Object.keys(instance.roles || {})) {
|
|
||||||
const tags = Object.keys(instance.roles?.[role].tags || {});
|
|
||||||
const machines = Object.keys(instance.roles?.[role].machines || {});
|
|
||||||
|
|
||||||
const machineTags = machines.map((m) => ({
|
|
||||||
value: "m_" + m,
|
|
||||||
label: m,
|
|
||||||
type: "machine" as const,
|
|
||||||
}));
|
|
||||||
const tagsTags = tags.map((t) => {
|
|
||||||
return {
|
|
||||||
value: "t_" + t,
|
|
||||||
label: t,
|
|
||||||
type: "tag" as const,
|
|
||||||
members: Object.entries(machinesQuery.data || {})
|
|
||||||
.filter(([_, m]) => m.tags?.includes(t))
|
|
||||||
.map(([k]) => k),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
console.log("Members for role", role, [
|
|
||||||
...machineTags,
|
|
||||||
...tagsTags,
|
|
||||||
]);
|
|
||||||
if (!store.roles) {
|
|
||||||
set("roles", {});
|
|
||||||
}
|
|
||||||
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
|
|
||||||
a.label.localeCompare(b.label),
|
|
||||||
);
|
|
||||||
set("roles", role, roleMembers);
|
|
||||||
console.log("set", store.roles);
|
|
||||||
}
|
|
||||||
// Initialize the roles with the existing members
|
|
||||||
}
|
|
||||||
|
|
||||||
stepper.next();
|
|
||||||
}}
|
|
||||||
options={moduleOptions()}
|
|
||||||
renderItem={(item, opts) => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
|
|
||||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
|
||||||
<Icon icon="Code" />
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full flex-col">
|
|
||||||
<Combobox.ItemLabel class="flex gap-1.5">
|
|
||||||
<Show when={item.raw.instance_refs.length > 0}>
|
|
||||||
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
|
||||||
<Typography hierarchy="label" weight="bold" size="xxs">
|
|
||||||
Added
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
</Combobox.ItemLabel>
|
|
||||||
<Typography
|
|
||||||
hierarchy="body"
|
|
||||||
size="xxs"
|
|
||||||
weight="normal"
|
|
||||||
color="quaternary"
|
|
||||||
inverted
|
|
||||||
class="flex justify-between"
|
|
||||||
>
|
|
||||||
<span class="inline-block max-w-80 truncate align-middle">
|
|
||||||
{item.raw.info.manifest.description}
|
|
||||||
</span>
|
|
||||||
<span class="inline-block max-w-32 truncate align-middle">
|
|
||||||
<Show when={!item.raw.native} fallback="by clan-core">
|
|
||||||
by {item.raw.usage_ref.input}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
||||||
createMemo<TagType[]>(() => {
|
createMemo<TagType[]>(() => {
|
||||||
@@ -206,7 +66,7 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
|||||||
label: tag,
|
label: tag,
|
||||||
value: "t_" + tag,
|
value: "t_" + tag,
|
||||||
members: Object.entries(machines)
|
members: Object.entries(machines)
|
||||||
.filter(([_, v]) => v.tags?.includes(tag))
|
.filter(([_, v]) => v.data.tags?.includes(tag))
|
||||||
.map(([k]) => k),
|
.map(([k]) => k),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -215,22 +75,86 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sanitizeModuleInput = (
|
||||||
|
input: string | undefined,
|
||||||
|
core_input_name: string,
|
||||||
|
) => {
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
if (input === core_input_name) return null;
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
interface RolesForm extends FieldValues {
|
interface RolesForm extends FieldValues {
|
||||||
roles: Record<string, string[]>;
|
roles: Record<string, string[]>;
|
||||||
instanceName: string;
|
instanceName: string;
|
||||||
}
|
}
|
||||||
const ConfigureService = () => {
|
const ConfigureService = () => {
|
||||||
const stepper = useStepper<ServiceSteps>();
|
const stepper = useStepper<ServiceSteps>();
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
const machinesQuery = useMachinesQuery(clanURI);
|
||||||
|
const serviceModulesQuery = useServiceModules(clanURI);
|
||||||
|
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||||
|
const routerProps = useServiceParams();
|
||||||
|
|
||||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||||
|
|
||||||
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
// Default to the module name, until we support multiple instances
|
// Default to the module name, until we support multiple instances
|
||||||
instanceName: store.module.name,
|
instanceName: routerProps.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const machinesQuery = useMachinesQuery(useClanURI());
|
const selectedModule = createMemo(() => {
|
||||||
|
if (!serviceModulesQuery.data) return undefined;
|
||||||
|
return serviceModulesQuery.data.modules.find(
|
||||||
|
(m) =>
|
||||||
|
m.usage_ref.name === routerProps.name &&
|
||||||
|
// left side is string | null
|
||||||
|
// right side is string | undefined
|
||||||
|
m.usage_ref.input ===
|
||||||
|
sanitizeModuleInput(
|
||||||
|
routerProps.input,
|
||||||
|
serviceModulesQuery.data.core_input_name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [serviceInstancesQuery.data, machinesQuery.data] as const,
|
||||||
|
([instances, machines]) => {
|
||||||
|
// Wait for all queries to be ready
|
||||||
|
if (!instances || !machines) return;
|
||||||
|
const instance = instances[routerProps.id || routerProps.name];
|
||||||
|
|
||||||
|
set("roles", {});
|
||||||
|
if (!instance) {
|
||||||
|
set("action", "create");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const role of Object.keys(instance.roles || {})) {
|
||||||
|
// Get Role members
|
||||||
|
const roleMembers = getRoleMembers(instance, machines, role);
|
||||||
|
set("roles", role, roleMembers);
|
||||||
|
}
|
||||||
|
set("action", "update");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentModuleRoles = createMemo(() => {
|
||||||
|
const module = selectedModule();
|
||||||
|
if (!module) return [];
|
||||||
|
return Object.keys(module.info.roles).map((role) => ({
|
||||||
|
role,
|
||||||
|
members: store.roles?.[role] || [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
const tagsQuery = useTags(useClanURI());
|
const tagsQuery = useTags(useClanURI());
|
||||||
|
|
||||||
const options = useOptions(tagsQuery, machinesQuery);
|
const options = useOptions(tagsQuery, machinesQuery);
|
||||||
@@ -249,13 +173,15 @@ const ConfigureService = () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
store.handleSubmit(
|
store.handleSubmit(
|
||||||
{
|
{
|
||||||
name: values.instanceName,
|
name: values.instanceName,
|
||||||
module: {
|
module: {
|
||||||
name: store.module.name,
|
name: routerProps.name,
|
||||||
input: store.module.input,
|
input: sanitizeModuleInput(
|
||||||
|
routerProps.input,
|
||||||
|
serviceModulesQuery.data?.core_input_name || "clan-core",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
roles,
|
roles,
|
||||||
},
|
},
|
||||||
@@ -271,7 +197,7 @@ const ConfigureService = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||||
{store.module.name}
|
{routerProps.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Field name="instanceName">
|
<Field name="instanceName">
|
||||||
{(field, input) => (
|
{(field, input) => (
|
||||||
@@ -294,54 +220,70 @@ const ConfigureService = () => {
|
|||||||
ghost
|
ghost
|
||||||
size="s"
|
size="s"
|
||||||
class="ml-auto"
|
class="ml-auto"
|
||||||
onClick={store.close}
|
onClick={() => store.close()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
<For each={Object.keys(store.module.raw?.info.roles || {})}>
|
<Show
|
||||||
{(role) => {
|
when={serviceModulesQuery.data && store.roles}
|
||||||
const values = store.roles?.[role] || [];
|
fallback={<div>Loading...</div>}
|
||||||
return (
|
>
|
||||||
<TagSelect<TagType>
|
<For each={currentModuleRoles()}>
|
||||||
label={role}
|
{(role) => {
|
||||||
renderItem={(item: TagType) => (
|
return (
|
||||||
<Tag
|
<TagSelect<TagType>
|
||||||
inverted
|
label={role.role}
|
||||||
icon={(tag) => (
|
renderItem={(item: TagType) => (
|
||||||
<Icon
|
<Tag
|
||||||
icon={item.type === "machine" ? "Machine" : "Tag"}
|
inverted
|
||||||
size="0.5rem"
|
icon={(tag) => (
|
||||||
inverted={tag.inverted}
|
<Icon
|
||||||
/>
|
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||||
)}
|
size="0.5rem"
|
||||||
>
|
inverted={tag.inverted}
|
||||||
{item.label}
|
/>
|
||||||
</Tag>
|
)}
|
||||||
)}
|
>
|
||||||
values={values}
|
{item.label}
|
||||||
options={options()}
|
</Tag>
|
||||||
onClick={() => {
|
)}
|
||||||
set("currentRole", role);
|
values={role.members}
|
||||||
stepper.next();
|
options={options()}
|
||||||
}}
|
onClick={() => {
|
||||||
/>
|
set("currentRole", role.role);
|
||||||
);
|
stepper.next();
|
||||||
}}
|
}}
|
||||||
</For>
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||||
<BackButton ghost hierarchy="primary" class="mr-auto" />
|
<Button
|
||||||
|
hierarchy="secondary"
|
||||||
<Button hierarchy="secondary" type="submit">
|
type="submit"
|
||||||
<Show when={store.action === "create"}>Add Service</Show>
|
loading={!serviceInstancesQuery.data}
|
||||||
<Show when={store.action === "update"}>Save Changes</Show>
|
>
|
||||||
|
<Show when={serviceInstancesQuery.data}>
|
||||||
|
{(d) => (
|
||||||
|
<>
|
||||||
|
<Show
|
||||||
|
when={Object.keys(d()).includes(routerProps.id)}
|
||||||
|
fallback={"Add Service"}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type TagType =
|
export type TagType =
|
||||||
| {
|
| {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -362,31 +304,36 @@ const ConfigureRole = () => {
|
|||||||
store.roles?.[store.currentRole || ""] || [],
|
store.roles?.[store.currentRole || ""] || [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clanUri = useClanURI();
|
||||||
|
const machinesQuery = useMachinesQuery(clanUri);
|
||||||
|
|
||||||
const lastClickedMachine = useMachineClick();
|
const lastClickedMachine = useMachineClick();
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
console.log("Current role", store.currentRole, members());
|
on(members, (m) => {
|
||||||
clearAllHighlights();
|
clearAllHighlights();
|
||||||
setHighlightGroups({
|
setHighlightGroups({
|
||||||
[store.currentRole as string]: new Set(
|
[store.currentRole as string]: new Set(
|
||||||
members().flatMap((m) => {
|
m.flatMap((m) => {
|
||||||
if (m.type === "machine") return m.label;
|
if (m.type === "machine") return m.label;
|
||||||
|
|
||||||
return m.members;
|
return m.members;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
console.log("now", highlightGroups);
|
onMount(() => {
|
||||||
|
setHighlightGroups(() => ({}));
|
||||||
});
|
});
|
||||||
onMount(() => setHighlightGroups(() => ({})));
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(lastClickedMachine, (machine) => {
|
on(lastClickedMachine, (machine) => {
|
||||||
// const machine = lastClickedMachine();
|
// const machine = lastClickedMachine();
|
||||||
const currentMembers = members();
|
const currentMembers = members();
|
||||||
console.log("Clicked machine", machine, currentMembers);
|
|
||||||
if (!machine) return;
|
if (!machine) return;
|
||||||
|
|
||||||
const machineTagName = "m_" + machine;
|
const machineTagName = "m_" + machine;
|
||||||
|
|
||||||
const existing = currentMembers.find((m) => m.value === machineTagName);
|
const existing = currentMembers.find((m) => m.value === machineTagName);
|
||||||
@@ -403,7 +350,6 @@ const ConfigureRole = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const machinesQuery = useMachinesQuery(useClanURI());
|
|
||||||
const tagsQuery = useTags(useClanURI());
|
const tagsQuery = useTags(useClanURI());
|
||||||
|
|
||||||
const options = useOptions(tagsQuery, machinesQuery);
|
const options = useOptions(tagsQuery, machinesQuery);
|
||||||
@@ -428,12 +374,7 @@ const ConfigureRole = () => {
|
|||||||
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
|
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
|
||||||
headerChildren={
|
headerChildren={
|
||||||
<div class="flex w-full gap-2.5">
|
<div class="flex w-full gap-2.5">
|
||||||
<BackButton
|
<BackButton ghost size="xs" hierarchy="primary" />
|
||||||
ghost
|
|
||||||
size="xs"
|
|
||||||
hierarchy="primary"
|
|
||||||
// onClick={() => clearAllHighlights()}
|
|
||||||
/>
|
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="body"
|
hierarchy="body"
|
||||||
size="s"
|
size="s"
|
||||||
@@ -505,10 +446,6 @@ const ConfigureRole = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
|
||||||
id: "select:service",
|
|
||||||
content: SelectService,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "view:members",
|
id: "view:members",
|
||||||
content: ConfigureService,
|
content: ConfigureService,
|
||||||
@@ -522,79 +459,34 @@ const steps = [
|
|||||||
|
|
||||||
export type ServiceSteps = typeof steps;
|
export type ServiceSteps = typeof steps;
|
||||||
|
|
||||||
// TODO: Ideally we would impot this from a backend model package
|
|
||||||
export interface InventoryInstance {
|
|
||||||
name: string;
|
|
||||||
module: {
|
|
||||||
name: string;
|
|
||||||
input?: string | null;
|
|
||||||
};
|
|
||||||
roles: Record<string, RoleType>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleType {
|
|
||||||
machines: Record<string, { settings?: unknown }>;
|
|
||||||
tags: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceStoreType {
|
|
||||||
module: {
|
|
||||||
name: string;
|
|
||||||
input?: string | null;
|
|
||||||
raw?: ModuleItem;
|
|
||||||
};
|
|
||||||
roles: Record<string, TagType[]>;
|
|
||||||
currentRole?: string;
|
|
||||||
close: () => void;
|
|
||||||
handleSubmit: SubmitServiceHandler;
|
|
||||||
action: "create" | "update";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubmitServiceHandler = (
|
|
||||||
values: InventoryInstance,
|
|
||||||
action: "create" | "update",
|
|
||||||
) => void | Promise<void>;
|
|
||||||
|
|
||||||
interface ServiceWorkflowProps {
|
interface ServiceWorkflowProps {
|
||||||
initialStep?: ServiceSteps[number]["id"];
|
initialStep?: ServiceSteps[number]["id"];
|
||||||
initialStore?: Partial<ServiceStoreType>;
|
initialStore?: Partial<ServiceStoreType>;
|
||||||
onClose?: () => void;
|
onClose: () => void;
|
||||||
handleSubmit: SubmitServiceHandler;
|
handleSubmit: SubmitServiceHandler;
|
||||||
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||||
const stepper = createStepper(
|
const stepper = createStepper(
|
||||||
{ steps },
|
{ steps },
|
||||||
{
|
{
|
||||||
initialStep: props.initialStep || "select:service",
|
initialStep: props.initialStep || "view:members",
|
||||||
initialStoreData: {
|
initialStoreData: {
|
||||||
...props.initialStore,
|
...props.initialStore,
|
||||||
close: () => props.onClose?.(),
|
close: props.onClose,
|
||||||
handleSubmit: props.handleSubmit,
|
handleSubmit: props.handleSubmit,
|
||||||
} satisfies Partial<ServiceStoreType>,
|
} satisfies Partial<ServiceStoreType>,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (stepper.currentStep().id !== "select:members") {
|
if (stepper.currentStep().id !== "select:members") {
|
||||||
clearAllHighlights();
|
clearAllHighlights();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ref: HTMLDivElement;
|
|
||||||
useClickOutside(
|
|
||||||
() => ref,
|
|
||||||
() => {
|
|
||||||
if (stepper.currentStep().id === "select:service") props.onClose?.();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||||
ref={(e) => (ref = e)}
|
|
||||||
id="add-service"
|
|
||||||
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
|
||||||
{...props.rootProps}
|
|
||||||
>
|
|
||||||
<StepperProvider stepper={stepper}>
|
<StepperProvider stepper={stepper}>
|
||||||
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
||||||
</StepperProvider>
|
</StepperProvider>
|
||||||
|
|||||||
83
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal file
83
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
MachinesQuery,
|
||||||
|
ServiceInstancesQuery,
|
||||||
|
ServiceModules,
|
||||||
|
} from "@/src/hooks/queries";
|
||||||
|
import { TagType } from "./Service";
|
||||||
|
|
||||||
|
export interface ServiceStoreType {
|
||||||
|
roles: Record<string, TagType[]>;
|
||||||
|
currentRole?: string;
|
||||||
|
close: () => void;
|
||||||
|
handleSubmit: SubmitServiceHandler;
|
||||||
|
action: "create" | "update";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Ideally we would impot this from a backend model package
|
||||||
|
export interface InventoryInstance {
|
||||||
|
name: string;
|
||||||
|
module: {
|
||||||
|
name: string;
|
||||||
|
input?: string | null;
|
||||||
|
};
|
||||||
|
roles: Record<string, RoleType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleType {
|
||||||
|
machines: Record<string, { settings?: unknown }>;
|
||||||
|
tags: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmitServiceHandler = (
|
||||||
|
values: InventoryInstance,
|
||||||
|
action: "create" | "update",
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export type ModuleItem = ServiceModules["modules"][number];
|
||||||
|
|
||||||
|
export interface Module {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
raw: ModuleItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValueOf<T> = T[keyof T];
|
||||||
|
|
||||||
|
export type Instance = ValueOf<NonNullable<ServiceInstancesQuery["data"]>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all members (machines and tags) for a given role in a service instance
|
||||||
|
*
|
||||||
|
* TODO: Make this native feature of the API
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function getRoleMembers(
|
||||||
|
instance: Instance,
|
||||||
|
all_machines: NonNullable<MachinesQuery["data"]>,
|
||||||
|
role: string,
|
||||||
|
) {
|
||||||
|
const tags = Object.keys(instance.roles?.[role].tags || {});
|
||||||
|
const machines = Object.keys(instance.roles?.[role].machines || {});
|
||||||
|
|
||||||
|
const machineTags = machines.map((m) => ({
|
||||||
|
value: "m_" + m,
|
||||||
|
label: m,
|
||||||
|
type: "machine" as const,
|
||||||
|
}));
|
||||||
|
const tagsTags = tags.map((t) => {
|
||||||
|
return {
|
||||||
|
value: "t_" + t,
|
||||||
|
label: t,
|
||||||
|
type: "tag" as const,
|
||||||
|
members: Object.entries(all_machines)
|
||||||
|
.filter(([_, m]) => m.data.tags?.includes(t))
|
||||||
|
.map(([k]) => k),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("Members for role", role, [...machineTags, ...tagsTags]);
|
||||||
|
|
||||||
|
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
|
||||||
|
a.label.localeCompare(b.label),
|
||||||
|
);
|
||||||
|
return roleMembers;
|
||||||
|
}
|
||||||
@@ -24,19 +24,10 @@ clangStdenv.mkDerivation {
|
|||||||
domain = "git.clan.lol";
|
domain = "git.clan.lol";
|
||||||
owner = "clan";
|
owner = "clan";
|
||||||
repo = "webview";
|
repo = "webview";
|
||||||
rev = "ef481aca8e531f6677258ca911c61aaaf71d2214";
|
rev = "c27041cb50f79c197080a3f4fa2bad4557ef3234";
|
||||||
hash = "sha256-KF9ESpo40z6VXyYsZCLWJAIh0RFe1Zy/Qw4k7cTpoYU=";
|
hash = "sha256-xNkX7O+GFMbv3YnXPrtO6vw+BUqCbVeFd8FjgPKfEG0=";
|
||||||
};
|
};
|
||||||
|
|
||||||
# @Mic92: Where is this revision coming from? I can't see it in any of the branches.
|
|
||||||
# I removed the icon python code for now
|
|
||||||
# src = pkgs.fetchFromGitHub {
|
|
||||||
# owner = "clan-lol";
|
|
||||||
# repo = "webview";
|
|
||||||
# rev = "7d24f0192765b7e08f2d712fae90c046d08f318e";
|
|
||||||
# hash = "sha256-yokVI9tFiEEU5M/S2xAeJOghqqiCvTelLo8WLKQZsSY=";
|
|
||||||
# };
|
|
||||||
|
|
||||||
outputs = [
|
outputs = [
|
||||||
"out"
|
"out"
|
||||||
"dev"
|
"dev"
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ def get_machines_for_update(
|
|||||||
machines_to_update = list(
|
machines_to_update = list(
|
||||||
filter(
|
filter(
|
||||||
requires_explicit_update,
|
requires_explicit_update,
|
||||||
instantiate_inventory_to_machines(flake, machines_with_tags).values(),
|
instantiate_inventory_to_machines(
|
||||||
|
flake, {name: m.data for name, m in machines_with_tags.items()}
|
||||||
|
).values(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# all machines that are in the clan but not included in the update list
|
# all machines that are in the clan but not included in the update list
|
||||||
@@ -128,13 +130,13 @@ def get_machines_for_update(
|
|||||||
machines_to_update = []
|
machines_to_update = []
|
||||||
valid_names = validate_machine_names(explicit_names, flake)
|
valid_names = validate_machine_names(explicit_names, flake)
|
||||||
for name in valid_names:
|
for name in valid_names:
|
||||||
inventory_machine = machines_with_tags.get(name)
|
machine = machines_with_tags.get(name)
|
||||||
if not inventory_machine:
|
if not machine:
|
||||||
msg = "This is an internal bug"
|
msg = "This is an internal bug"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
machines_to_update.append(
|
machines_to_update.append(
|
||||||
Machine.from_inventory(name, flake, inventory_machine),
|
Machine.from_inventory(name, flake, machine.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
return machines_to_update
|
return machines_to_update
|
||||||
|
|||||||
39
pkgs/clan-cli/clan_cli/python-deps.nix
Normal file
39
pkgs/clan-cli/clan_cli/python-deps.nix
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
python3,
|
||||||
|
fetchFromGitHub,
|
||||||
|
}:
|
||||||
|
rec {
|
||||||
|
asyncore-wsgi = python3.pkgs.buildPythonPackage rec {
|
||||||
|
pname = "asyncore-wsgi";
|
||||||
|
version = "0.0.11";
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "romanvm";
|
||||||
|
repo = "asyncore-wsgi";
|
||||||
|
rev = "${version}";
|
||||||
|
sha256 = "sha256-06rWCC8qZb9H9qPUDQpzASKOY4VX+Y+Bm9a5e71Hqhc=";
|
||||||
|
};
|
||||||
|
pyproject = true;
|
||||||
|
buildInputs = [
|
||||||
|
python3.pkgs.setuptools
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
web-pdb = python3.pkgs.buildPythonPackage rec {
|
||||||
|
pname = "web-pdb";
|
||||||
|
version = "1.6.3";
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "romanvm";
|
||||||
|
repo = "python-web-pdb";
|
||||||
|
rev = "${version}";
|
||||||
|
sha256 = "sha256-VG0mHbogx0n1f38h9VVxFQgjvghipAf1rb43/Bwb/8I=";
|
||||||
|
};
|
||||||
|
pyproject = true;
|
||||||
|
buildInputs = [
|
||||||
|
python3.pkgs.setuptools
|
||||||
|
];
|
||||||
|
propagatedBuildInputs = [
|
||||||
|
python3.pkgs.bottle
|
||||||
|
asyncore-wsgi
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -431,20 +431,22 @@ def test_generated_shared_secret_sops(
|
|||||||
generator_m1 = Generator(
|
generator_m1 = Generator(
|
||||||
"my_shared_generator",
|
"my_shared_generator",
|
||||||
share=True,
|
share=True,
|
||||||
machine="machine1",
|
|
||||||
_flake=machine1.flake,
|
_flake=machine1.flake,
|
||||||
)
|
)
|
||||||
generator_m2 = Generator(
|
generator_m2 = Generator(
|
||||||
"my_shared_generator",
|
"my_shared_generator",
|
||||||
share=True,
|
share=True,
|
||||||
machine="machine2",
|
|
||||||
_flake=machine2.flake,
|
_flake=machine2.flake,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert m1_sops_store.exists(generator_m1, "my_shared_secret")
|
assert m1_sops_store.exists(generator_m1, "my_shared_secret")
|
||||||
assert m2_sops_store.exists(generator_m2, "my_shared_secret")
|
assert m2_sops_store.exists(generator_m2, "my_shared_secret")
|
||||||
assert m1_sops_store.machine_has_access(generator_m1, "my_shared_secret")
|
assert m1_sops_store.machine_has_access(
|
||||||
assert m2_sops_store.machine_has_access(generator_m2, "my_shared_secret")
|
generator_m1, "my_shared_secret", "machine1"
|
||||||
|
)
|
||||||
|
assert m2_sops_store.machine_has_access(
|
||||||
|
generator_m2, "my_shared_secret", "machine2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
@@ -499,6 +501,7 @@ def test_generate_secret_var_password_store(
|
|||||||
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
||||||
assert check_vars(machine.name, machine.flake)
|
assert check_vars(machine.name, machine.flake)
|
||||||
store = password_store.SecretStore(flake=flake_obj)
|
store = password_store.SecretStore(flake=flake_obj)
|
||||||
|
store.init_pass_command(machine="my_machine")
|
||||||
my_generator = Generator(
|
my_generator = Generator(
|
||||||
"my_generator",
|
"my_generator",
|
||||||
share=False,
|
share=False,
|
||||||
@@ -744,6 +747,74 @@ def test_shared_vars_must_never_depend_on_machine_specific_vars(
|
|||||||
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_shared_vars_regeneration(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
flake_with_sops: ClanFlake,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure that is a shared generator gets generated on one machine, dependents of that
|
||||||
|
shared generator on other machines get re-generated as well.
|
||||||
|
"""
|
||||||
|
flake = flake_with_sops
|
||||||
|
|
||||||
|
machine1_config = flake.machines["machine1"]
|
||||||
|
machine1_config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
|
||||||
|
shared_generator = machine1_config["clan"]["core"]["vars"]["generators"][
|
||||||
|
"shared_generator"
|
||||||
|
]
|
||||||
|
shared_generator["share"] = True
|
||||||
|
shared_generator["files"]["my_value"]["secret"] = False
|
||||||
|
shared_generator["script"] = 'echo "$RANDOM" > "$out"/my_value'
|
||||||
|
child_generator = machine1_config["clan"]["core"]["vars"]["generators"][
|
||||||
|
"child_generator"
|
||||||
|
]
|
||||||
|
child_generator["share"] = False
|
||||||
|
child_generator["files"]["my_value"]["secret"] = False
|
||||||
|
child_generator["dependencies"] = ["shared_generator"]
|
||||||
|
child_generator["script"] = 'cat "$in"/shared_generator/my_value > "$out"/my_value'
|
||||||
|
# machine 2 is equivalent to machine 1
|
||||||
|
flake.machines["machine2"] = machine1_config
|
||||||
|
flake.refresh()
|
||||||
|
monkeypatch.chdir(flake.path)
|
||||||
|
machine1 = Machine(name="machine1", flake=Flake(str(flake.path)))
|
||||||
|
machine2 = Machine(name="machine2", flake=Flake(str(flake.path)))
|
||||||
|
in_repo_store_1 = in_repo.FactStore(machine1.flake)
|
||||||
|
in_repo_store_2 = in_repo.FactStore(machine2.flake)
|
||||||
|
# Create generators with machine context for testing
|
||||||
|
child_gen_m1 = Generator(
|
||||||
|
"child_generator", share=False, machine="machine1", _flake=machine1.flake
|
||||||
|
)
|
||||||
|
child_gen_m2 = Generator(
|
||||||
|
"child_generator", share=False, machine="machine2", _flake=machine2.flake
|
||||||
|
)
|
||||||
|
# generate for machine 1
|
||||||
|
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
|
||||||
|
# generate for machine 2
|
||||||
|
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
|
||||||
|
# child value should be the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after initial generation"
|
||||||
|
|
||||||
|
# regenerate on all machines
|
||||||
|
cli.run(
|
||||||
|
["vars", "generate", "--flake", str(flake.path), "--regenerate"],
|
||||||
|
)
|
||||||
|
# ensure child value after --regenerate is the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after regenerating all machines"
|
||||||
|
|
||||||
|
# regenerate for machine 1
|
||||||
|
cli.run(
|
||||||
|
["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"]
|
||||||
|
)
|
||||||
|
# ensure child value after --regenerate is the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after regenerating machine1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
def test_multi_machine_shared_vars(
|
def test_multi_machine_shared_vars(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -816,8 +887,8 @@ def test_multi_machine_shared_vars(
|
|||||||
assert new_value_1 != m1_value
|
assert new_value_1 != m1_value
|
||||||
# ensure that both machines still have access to the same secret
|
# ensure that both machines still have access to the same secret
|
||||||
assert new_secret_1 == new_secret_2
|
assert new_secret_1 == new_secret_2
|
||||||
assert sops_store_1.machine_has_access(generator_m1, "my_secret")
|
assert sops_store_1.machine_has_access(generator_m1, "my_secret", "machine1")
|
||||||
assert sops_store_2.machine_has_access(generator_m2, "my_secret")
|
assert sops_store_2.machine_has_access(generator_m2, "my_secret", "machine2")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
@@ -862,7 +933,7 @@ def test_api_set_prompts(
|
|||||||
|
|
||||||
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
|
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
|
||||||
generators = get_generators(
|
generators = get_generators(
|
||||||
machine=machine,
|
machines=[machine],
|
||||||
full_closure=True,
|
full_closure=True,
|
||||||
include_previous_values=True,
|
include_previous_values=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,11 +42,7 @@ class StoreBase(ABC):
|
|||||||
"""Get machine name from generator, asserting it's not None for now."""
|
"""Get machine name from generator, asserting it's not None for now."""
|
||||||
if generator.machine is None:
|
if generator.machine is None:
|
||||||
if generator.share:
|
if generator.share:
|
||||||
# Shared generators don't need a machine for most operations
|
return "__shared"
|
||||||
# but some operations (like SOPS key management) might still need one
|
|
||||||
# This is a temporary workaround - we should handle this better
|
|
||||||
msg = f"Shared generator '{generator.name}' requires a machine context for this operation"
|
|
||||||
raise ClanError(msg)
|
|
||||||
msg = f"Generator '{generator.name}' has no machine associated"
|
msg = f"Generator '{generator.name}' has no machine associated"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
return generator.machine
|
return generator.machine
|
||||||
@@ -62,6 +58,7 @@ class StoreBase(ABC):
|
|||||||
generator: "Generator",
|
generator: "Generator",
|
||||||
var: "Var",
|
var: "Var",
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""Override this method to implement the actual creation of the file"""
|
"""Override this method to implement the actual creation of the file"""
|
||||||
|
|
||||||
@@ -140,16 +137,20 @@ class StoreBase(ABC):
|
|||||||
generator: "Generator",
|
generator: "Generator",
|
||||||
var: "Var",
|
var: "Var",
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
is_migration: bool = False,
|
is_migration: bool = False,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
changed_files: list[Path] = []
|
changed_files: list[Path] = []
|
||||||
|
|
||||||
# if generator was switched from shared to per-machine or vice versa,
|
# if generator was switched from shared to per-machine or vice versa,
|
||||||
# remove the old var first
|
# remove the old var first
|
||||||
if self.exists(
|
prev_generator = dataclasses.replace(
|
||||||
gen := dataclasses.replace(generator, share=not generator.share), var.name
|
generator,
|
||||||
):
|
share=not generator.share,
|
||||||
changed_files += self.delete(gen, var.name)
|
machine=machine if generator.share else None,
|
||||||
|
)
|
||||||
|
if self.exists(prev_generator, var.name):
|
||||||
|
changed_files += self.delete(prev_generator, var.name)
|
||||||
|
|
||||||
if self.exists(generator, var.name):
|
if self.exists(generator, var.name):
|
||||||
if self.is_secret_store:
|
if self.is_secret_store:
|
||||||
@@ -161,7 +162,7 @@ class StoreBase(ABC):
|
|||||||
else:
|
else:
|
||||||
old_val = None
|
old_val = None
|
||||||
old_val_str = "<not set>"
|
old_val_str = "<not set>"
|
||||||
new_file = self._set(generator, var, value)
|
new_file = self._set(generator, var, value, machine)
|
||||||
action_str = "Migrated" if is_migration else "Updated"
|
action_str = "Migrated" if is_migration else "Updated"
|
||||||
log_info: Callable
|
log_info: Callable
|
||||||
if generator.machine is None:
|
if generator.machine is None:
|
||||||
@@ -169,8 +170,8 @@ class StoreBase(ABC):
|
|||||||
else:
|
else:
|
||||||
from clan_lib.machines.machines import Machine # noqa: PLC0415
|
from clan_lib.machines.machines import Machine # noqa: PLC0415
|
||||||
|
|
||||||
machine = Machine(name=generator.machine, flake=self.flake)
|
machine_obj = Machine(name=generator.machine, flake=self.flake)
|
||||||
log_info = machine.info
|
log_info = machine_obj.info
|
||||||
if self.is_secret_store:
|
if self.is_secret_store:
|
||||||
log.info(f"{action_str} secret var {generator.name}/{var.name}\n")
|
log.info(f"{action_str} secret var {generator.name}/{var.name}\n")
|
||||||
elif value != old_val:
|
elif value != old_val:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user