Compare commits
145 Commits
revers-upd
...
vars-docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da84b911b0 | ||
|
|
a6214f431d | ||
|
|
b8890f6732 | ||
|
|
370b4f535d | ||
|
|
ef66c9b5be | ||
|
|
79d44f7c30 | ||
|
|
e72e100965 | ||
|
|
180e2a601c | ||
|
|
90d265089b | ||
|
|
a0fa52fded | ||
|
|
af4e9e784b | ||
|
|
cb162a53b8 | ||
|
|
16e506ea1a | ||
|
|
11ec94c17f | ||
|
|
8468b1ebaf | ||
|
|
ec83130fa4 | ||
|
|
c1e41f8fd9 | ||
|
|
3630e778ad | ||
|
|
916186c465 | ||
|
|
25e733b8d7 | ||
|
|
2599998b17 | ||
|
|
56649b7fe2 | ||
|
|
fc85622e01 | ||
|
|
c499c563bb | ||
|
|
b255ba0367 | ||
|
|
493adebd7c | ||
|
|
cac2866356 | ||
|
|
981f6052ad | ||
|
|
6e888c38fa | ||
|
|
e953f807de | ||
|
|
c2534e9a42 | ||
|
|
b0feef1a40 | ||
|
|
d4c26087df | ||
|
|
1a9bbab667 | ||
|
|
b23171f291 | ||
|
|
087423597b | ||
|
|
602dc192f3 | ||
|
|
dba166cc8a | ||
|
|
21b872a1c9 | ||
|
|
be48ffe724 | ||
|
|
7673b72991 | ||
|
|
42bbd7c5fd | ||
|
|
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 | ||
|
|
758eacd27e |
@@ -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.diskoScript
|
||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
||||
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
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";
|
||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||
|
||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||
imports = [
|
||||
self.nixosModules.test-install-machine-without-system
|
||||
];
|
||||
};
|
||||
|
||||
clan.machines.test-install-machine-with-system =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
# https://git.clan.lol/clan/test-fixtures
|
||||
facter.reportPath = builtins.fetchurl {
|
||||
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${pkgs.hostPlatform.system}.json";
|
||||
sha256 =
|
||||
{
|
||||
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
|
||||
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
|
||||
}
|
||||
.${pkgs.hostPlatform.system};
|
||||
};
|
||||
facter.reportPath = import ./facter-report.nix pkgs.hostPlatform.system;
|
||||
|
||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||
|
||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||
};
|
||||
|
||||
flake.nixosModules = {
|
||||
test-install-machine-without-system =
|
||||
{ lib, modulesPath, ... }:
|
||||
@@ -159,6 +155,7 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.bash.drvPath
|
||||
pkgs.buildPackages.xorg.lndir
|
||||
(import ./facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.stdenvNoCC
|
||||
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
||||
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
pkgs.stdenv.drvPath
|
||||
pkgs.bash.drvPath
|
||||
pkgs.buildPackages.xorg.lndir
|
||||
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ inventory.instances = {
|
||||
borgbackup = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.client.machines."jon".settings = {
|
||||
destinations."storagebox" = {
|
||||
|
||||
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";
|
||||
manifest.name = "coredns";
|
||||
@@ -25,6 +26,12 @@
|
||||
# TODO: Set a default
|
||||
description = "IP for the DNS to listen on";
|
||||
};
|
||||
|
||||
options.dnsPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1053;
|
||||
description = "Port of the clan-internal DNS server";
|
||||
};
|
||||
};
|
||||
|
||||
perInstance =
|
||||
@@ -42,8 +49,8 @@
|
||||
}:
|
||||
{
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 53 ];
|
||||
networking.firewall.allowedUDPPorts = [ 53 ];
|
||||
networking.firewall.allowedTCPPorts = [ settings.dnsPort ];
|
||||
networking.firewall.allowedUDPPorts = [ settings.dnsPort ];
|
||||
|
||||
services.coredns =
|
||||
let
|
||||
@@ -74,16 +81,22 @@
|
||||
in
|
||||
{
|
||||
enable = true;
|
||||
config = ''
|
||||
. {
|
||||
forward . 1.1.1.1
|
||||
cache 30
|
||||
}
|
||||
config =
|
||||
|
||||
${settings.tld} {
|
||||
file ${zonefile}
|
||||
}
|
||||
'';
|
||||
let
|
||||
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
|
||||
description = "IP on which the services will listen";
|
||||
};
|
||||
|
||||
options.dnsPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1053;
|
||||
description = "Port of the clan-internal DNS server";
|
||||
};
|
||||
};
|
||||
|
||||
perInstance =
|
||||
{ roles, ... }:
|
||||
{ roles, settings, ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{ lib, ... }:
|
||||
@@ -147,7 +166,7 @@
|
||||
];
|
||||
stub-zone = map (m: {
|
||||
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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,18 +95,15 @@
|
||||
for m in machines:
|
||||
m.wait_for_unit("network-online.target")
|
||||
|
||||
# import time
|
||||
# time.sleep(2333333)
|
||||
|
||||
# This should work, but is borken in tests i think? Instead we dig directly
|
||||
|
||||
# client.succeed("curl -k -v http://one.foo")
|
||||
# client.succeed("curl -k -v http://two.foo")
|
||||
|
||||
answer = client.succeed("dig @192.168.1.2 one.foo")
|
||||
answer = client.succeed("dig @192.168.1.2 -p 1053 one.foo")
|
||||
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"
|
||||
|
||||
'';
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
systemd.services.telegraf-json = {
|
||||
enable = true;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "telegraf.service" ];
|
||||
wants = [ "telegraf.service" ];
|
||||
serviceConfig = {
|
||||
Restart = "on-failure";
|
||||
};
|
||||
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ inventory.instances = {
|
||||
clan-cache = {
|
||||
module = {
|
||||
name = "trusted-nix-caches";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.default.machines.draper = { };
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
user-alice = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.default.tags.all = { };
|
||||
roles.default.settings = {
|
||||
@@ -35,7 +35,7 @@
|
||||
user-bob = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.default.machines.bobs-laptop = { };
|
||||
roles.default.settings.user = "bob";
|
||||
|
||||
@@ -5,7 +5,7 @@ inventory.instances = {
|
||||
zerotier = {
|
||||
module = {
|
||||
name = "zerotier";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.peer.tags.all = { };
|
||||
roles.controller.machines.jon = { };
|
||||
@@ -18,7 +18,6 @@ All machines will be peers and connected to the zerotier network.
|
||||
Jon is the controller machine, which will will accept other machines into the network.
|
||||
Sara is a moon and sets the `stableEndpoint` setting with a publicly reachable IP, the moon is optional.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to set up and manage a [ZeroTier VPN](https://zerotier.com) for a clan network. Each VPN requires a single controller and can support multiple peers and optional moons for better connectivity.
|
||||
|
||||
16
devFlake/flake.lock
generated
16
devFlake/flake.lock
generated
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1756662818,
|
||||
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=",
|
||||
"lastModified": 1757449886,
|
||||
"narHash": "sha256-XNhjHidr4i581CVyufJtrleYYgn/55cQONYG3uvIYEY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09",
|
||||
"rev": "1779f9e0d8b45d88d7525665dd4d2a5b65041248",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,15 +107,15 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755555503,
|
||||
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
|
||||
"owner": "NuschtOS",
|
||||
"lastModified": 1757505024,
|
||||
"narHash": "sha256-AI4TKqIcUobFVqWD0N+6CWlJC5VMK7+HFtKKKyojG74=",
|
||||
"owner": "Qubasa",
|
||||
"repo": "search",
|
||||
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
|
||||
"rev": "ad978b39eba41d1cb5cf525fa146f91e6db2a895",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NuschtOS",
|
||||
"owner": "Qubasa",
|
||||
"repo": "search",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
inputs.flake-utils.inputs.systems.follows = "systems";
|
||||
|
||||
inputs.nuschtos.url = "github:NuschtOS/search";
|
||||
# Using my fork till this is merged: https://github.com/NuschtOS/search/pull/255
|
||||
inputs.nuschtos.url = "github:Qubasa/search";
|
||||
inputs.nuschtos.inputs.nixpkgs.follows = "nixpkgs-dev";
|
||||
|
||||
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||
|
||||
@@ -63,6 +63,12 @@ nav:
|
||||
- ClanServices: guides/clanServices.md
|
||||
- Backup & Restore: guides/backups.md
|
||||
- Disk Encryption: guides/disk-encryption.md
|
||||
- Vars:
|
||||
- Overview: guides/vars-overview.md
|
||||
- Getting Started: guides/vars-backend.md
|
||||
- Concepts: guides/vars-concepts.md
|
||||
- Advanced Examples: guides/vars-advanced-examples.md
|
||||
- Troubleshooting: guides/vars-troubleshooting.md
|
||||
- Age Plugins: guides/age-plugins.md
|
||||
- Secrets management: guides/secrets.md
|
||||
- Networking: guides/networking.md
|
||||
@@ -94,6 +100,7 @@ nav:
|
||||
- reference/clanServices/index.md
|
||||
- reference/clanServices/admin.md
|
||||
- reference/clanServices/borgbackup.md
|
||||
- reference/clanServices/certificates.md
|
||||
- reference/clanServices/coredns.md
|
||||
- reference/clanServices/data-mesher.md
|
||||
- reference/clanServices/dyndns.md
|
||||
|
||||
@@ -1,148 +1,45 @@
|
||||
# Generators
|
||||
|
||||
Defining a linux user's password via the nixos configuration previously required running `mkpasswd ...` and then copying the hash back into the nix configuration.
|
||||
Generators are the core mechanism of the clan vars system for automating the creation and management of generated files, especially secrets, in your NixOS configurations.
|
||||
|
||||
In this example, we will guide you through automating that interaction using clan `vars`.
|
||||
## What are Generators?
|
||||
|
||||
For a more general explanation of what clan vars are and how it works, see the intro of the [Reference Documentation for vars](../reference/clan.core/vars.md)
|
||||
Generators solve a common problem: instead of manually running commands like `mkpasswd` to create password hashes and copying them into your configuration, generators automate this process declaratively.
|
||||
|
||||
This guide assumes
|
||||
- Clan is set up already (see [Getting Started](../guides/getting-started/index.md))
|
||||
- a machine has been added to the clan (see [Adding Machines](../guides/getting-started/add-machines.md))
|
||||
A generator defines:
|
||||
|
||||
This section will walk you through the following steps:
|
||||
- **Input prompts**: Values to request from users (passwords, names, etc.)
|
||||
- **Generation script**: Logic to transform inputs into outputs
|
||||
- **Output files**: Generated files that can be secrets or public data
|
||||
- **Dependencies**: Other generators this one depends on
|
||||
- **Runtime inputs**: Tools and packages needed by the script
|
||||
|
||||
1. declare a `generator` in the machine's nixos configuration
|
||||
2. inspect the status via the Clan CLI
|
||||
3. generate the vars
|
||||
4. observe the changes
|
||||
5. update the machine
|
||||
6. share the root password between machines
|
||||
7. change the password
|
||||
## Key Benefits
|
||||
|
||||
## Declare a generator
|
||||
- **Reproducible**: Same inputs produce same outputs across machines
|
||||
- **Declarative**: Defined in your NixOS configuration alongside usage
|
||||
- **Secure**: Automatic handling of secrets storage and deployment
|
||||
- **Collaborative**: Shared generators work across team environments
|
||||
- **Automated**: No manual copy-paste of generated values
|
||||
|
||||
In this example, a `vars` `generator` is used to:
|
||||
## Common Use Cases
|
||||
|
||||
- prompt the user for the password
|
||||
- run the required `mkpasswd` command to generate the hash
|
||||
- store the hash in a file
|
||||
- expose the file path to the nixos configuration
|
||||
- **Password hashing**: Generate secure password hashes for user accounts
|
||||
- **SSH keys**: Create and manage SSH host and user keys
|
||||
- **Certificates**: Generate TLS certificates and certificate authorities
|
||||
- **API tokens**: Create secure random tokens for services
|
||||
- **Configuration files**: Generate config files that depend on secrets
|
||||
|
||||
Create a new nix file `root-password.nix` with the following content and import it into your `configuration.nix`
|
||||
```nix
|
||||
{config, pkgs, ...}: {
|
||||
## Learning Path
|
||||
|
||||
clan.core.vars.generators.root-password = {
|
||||
# prompt the user for a password
|
||||
# (`password-input` being an arbitrary name)
|
||||
prompts.password-input.description = "the root user's password";
|
||||
prompts.password-input.type = "hidden";
|
||||
# don't store the prompted password itself
|
||||
prompts.password-input.persist = false;
|
||||
# define an output file for storing the hash
|
||||
files.password-hash.secret = false;
|
||||
# define the logic for generating the hash
|
||||
script = ''
|
||||
cat $prompts/password-input | mkpasswd -m sha-512 > $out/password-hash
|
||||
'';
|
||||
# the tools required by the script
|
||||
runtimeInputs = [ pkgs.mkpasswd ];
|
||||
};
|
||||
1. **Start here**: [Vars Getting Started Guide](../guides/vars-backend.md) - Hands-on tutorial with practical examples
|
||||
2. **Understand the architecture**: [Vars Concepts Guide](../guides/vars-concepts.md) - Deep dive into design principles and patterns
|
||||
3. **Explore complex scenarios**: [Advanced Examples](../guides/vars-advanced-examples.md) - Real-world patterns and best practices
|
||||
4. **Troubleshoot issues**: [Troubleshooting Guide](../guides/vars-troubleshooting.md) - Common problems and solutions
|
||||
|
||||
# ensure users are immutable (otherwise the following config might be ignored)
|
||||
users.mutableUsers = false;
|
||||
# set the root password to the file containing the hash
|
||||
users.users.root.hashedPasswordFile =
|
||||
# clan will make sure, this path exists
|
||||
config.clan.core.vars.generators.root-password.files.password-hash.path;
|
||||
}
|
||||
```
|
||||
## API Reference
|
||||
|
||||
## Inspect the status
|
||||
For complete configuration options and technical details, see:
|
||||
|
||||
Executing `clan vars list`, you should see the following:
|
||||
```shellSession
|
||||
$ clan vars list my_machine
|
||||
root-password/password-hash: <not set>
|
||||
```
|
||||
|
||||
...indicating that the value `password-hash` for the generator `root-password` is not set yet.
|
||||
|
||||
## Generate the values
|
||||
|
||||
This step is not strictly necessary, as deploying the machine via `clan machines update` would trigger the generator as well.
|
||||
|
||||
To run the generator, execute `clan vars generate` for your machine
|
||||
```shellSession
|
||||
$ clan vars generate my_machine
|
||||
Enter the value for root-password/password-input (hidden):
|
||||
```
|
||||
|
||||
After entering the value, the updated status is reported:
|
||||
```shellSession
|
||||
Updated var root-password/password-hash
|
||||
old: <not set>
|
||||
new: $6$RMats/YMeypFtcYX$DUi...
|
||||
```
|
||||
|
||||
## Observe the changes
|
||||
|
||||
With the last step, a new file was created in your repository:
|
||||
`vars/per-machine/my-machine/root-password/password-hash/value`
|
||||
|
||||
If the repository is a git repository, a commit was created automatically:
|
||||
```shellSession
|
||||
$ git log -n1
|
||||
commit ... (HEAD -> master)
|
||||
Author: ...
|
||||
Date: ...
|
||||
|
||||
Update vars via generator root-password for machine grmpf-nix
|
||||
```
|
||||
|
||||
## Update the machine
|
||||
|
||||
```shell
|
||||
clan machines update my_machine
|
||||
```
|
||||
|
||||
## Share root password between machines
|
||||
|
||||
If we just imported the `root-password.nix` from above into more machines, clan would ask for a new password for each additional machine.
|
||||
|
||||
If the root password instead should only be entered once and shared across all machines, the generator defined above needs to be declared as `shared`, by adding `share = true` to it:
|
||||
```nix
|
||||
{config, pkgs, ...}: {
|
||||
clan.vars.generators.root-password = {
|
||||
share = true;
|
||||
# ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Importing that shared generator into each machine, will ensure that the password is only asked once the first machine gets updated and then re-used for all subsequent machines.
|
||||
|
||||
## Change the root password
|
||||
|
||||
Changing the password can be done via this command.
|
||||
Replace `my-machine` with your machine.
|
||||
If the password is shared, just pick any machine that has the generator declared.
|
||||
|
||||
```shellSession
|
||||
$ clan vars generate my-machine --generator root-password --regenerate
|
||||
...
|
||||
Enter the value for root-password/password-input (hidden):
|
||||
Input received. Processing...
|
||||
...
|
||||
Updated var root-password/password-hash
|
||||
old: $6$tb27m6EOdff.X9TM$19N...
|
||||
|
||||
new: $6$OyoQtDVzeemgh8EQ$zRK...
|
||||
```
|
||||
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Reference Documentation for `clan.core.vars` NixOS options](../reference/clan.core/vars.md)
|
||||
- [Reference Documentation for the `clan vars` CLI command](../reference/cli/vars.md)
|
||||
- [Vars NixOS Module Reference](../reference/clan.core/vars.md) - All configuration options
|
||||
- [Vars CLI Reference](../reference/cli/vars.md) - Command-line interface
|
||||
@@ -21,7 +21,7 @@ The following tutorial will walk through setting up a Backup service where the t
|
||||
|
||||
## Services
|
||||
|
||||
The inventory defines `services`. Membership of `machines` is defined via `roles` exclusively.
|
||||
The inventory defines `instances` of clan services. Membership of `machines` is defined via `roles` exclusively.
|
||||
|
||||
See each [modules documentation](../reference/clanServices/index.md) for its available roles.
|
||||
|
||||
@@ -31,9 +31,8 @@ A service can be added to one or multiple machines via `Roles`. Clan's `Role` in
|
||||
|
||||
Each service can still be customized and configured according to the modules options.
|
||||
|
||||
- Per instance configuration via `services.<serviceName>.<instanceName>.config`
|
||||
- Per role configuration via `services.<serviceName>.<instanceName>.roles.<roleName>.config`
|
||||
- Per machine configuration via `services.<serviceName>.<instanceName>.machines.<machineName>.config`
|
||||
- Per role configuration via `inventory.instances.<instanceName>.roles.<roleName>.settings`
|
||||
- Per machine configuration via `inventory.instances.<instanceName>.roles.<roleName>.machines.<machineName>.settings`
|
||||
|
||||
### Setting up the Backup Service
|
||||
|
||||
@@ -44,16 +43,17 @@ Each service can still be customized and configured according to the modules opt
|
||||
|
||||
See also: [Multiple Service Instances](#multiple-service-instances)
|
||||
|
||||
```{.nix hl_lines="6-7"}
|
||||
clan-core.lib.clan {
|
||||
inventory = {
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
# Machines can be added here.
|
||||
roles.client.machines = [ "jon" ];
|
||||
roles.server.machines = [ "backup_server" ];
|
||||
};
|
||||
```{.nix hl_lines="9-10"}
|
||||
{
|
||||
inventory.instances.instance_1 = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
|
||||
# Machines can be added here.
|
||||
roles.client.machines."jon" {};
|
||||
roles.server.machines."backup_server" = {};
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -66,8 +66,8 @@ It is possible to add services to multiple machines via tags as shown
|
||||
|
||||
!!! Example "Tags Example"
|
||||
|
||||
```{.nix hl_lines="5 8 14"}
|
||||
clan-core.lib.clan {
|
||||
```{.nix hl_lines="5 8 18"}
|
||||
{
|
||||
inventory = {
|
||||
machines = {
|
||||
"jon" = {
|
||||
@@ -76,13 +76,16 @@ It is possible to add services to multiple machines via tags as shown
|
||||
"sara" = {
|
||||
tags = [ "backup" ];
|
||||
};
|
||||
# ...
|
||||
};
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.client.tags = [ "backup" ];
|
||||
roles.server.machines = [ "backup_server" ];
|
||||
|
||||
instances.instance_1 = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
|
||||
roles.client.tags = [ "backup" ];
|
||||
roles.server.machines."backup_server" = {};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -98,22 +101,34 @@ It is possible to add services to multiple machines via tags as shown
|
||||
|
||||
In this example `backup_server` has role `client` and `server` in different instances.
|
||||
|
||||
```{.nix hl_lines="11 14"}
|
||||
clan-core.lib.clan {
|
||||
```{.nix hl_lines="17 26"}
|
||||
{
|
||||
inventory = {
|
||||
machines = {
|
||||
"jon" = {};
|
||||
"backup_server" = {};
|
||||
"backup_backup_server" = {}
|
||||
"backup_backup_server" = {};
|
||||
};
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.client.machines = [ "jon" ];
|
||||
roles.server.machines = [ "backup_server" ];
|
||||
|
||||
instances = {
|
||||
instance_1 = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
|
||||
roles.client.machines."jon" = {};
|
||||
roles.server.machines."backup_server" = {};
|
||||
};
|
||||
borgbackup.instance_2 = {
|
||||
roles.client.machines = [ "backup_server" ];
|
||||
roles.server.machines = [ "backup_backup_server" ];
|
||||
|
||||
instance_2 = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
|
||||
roles.client.machines."backup_server" = {};
|
||||
roles.server.machines."backup_backup_server" = {};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
This guide explains how to set up and manage
|
||||
[BorgBackup](https://borgbackup.readthedocs.io/) for secure, efficient backups
|
||||
in a clan network. BorgBackup provides:
|
||||
@@ -18,7 +17,7 @@ inventory.instances = {
|
||||
borgbackup = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.client.machines."jon".settings = {
|
||||
destinations."storagebox" = {
|
||||
@@ -177,7 +176,7 @@ storagebox::username@username.your-storagebox.de:/./borgbackup::jon-storagebox-2
|
||||
|
||||
### Restoring backups
|
||||
|
||||
For restoring a backup you have two options.
|
||||
For restoring a backup you have two options.
|
||||
|
||||
#### Full restoration
|
||||
|
||||
@@ -194,6 +193,3 @@ To restore only a specific service (e.g., `linkding`):
|
||||
```bash
|
||||
clan backups restore --service linkding jon borgbackup storagebox::u444061@u444061.your-storagebox.de:/./borgbackup::jon-storagebox-2025-07-24T06:02:35
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ This guide provides an example setup for a single-disk ZFS system with native en
|
||||
!!! Warning
|
||||
This configuration only applies to `systemd-boot` enabled systems and **requires** UEFI booting.
|
||||
|
||||
!!! Info "Secure Boot"
|
||||
This guide is compatible with systems that have [secure boot disabled](../guides/secure-boot.md). If you encounter boot issues, check if secure boot needs to be disabled in your UEFI settings.
|
||||
|
||||
Replace the highlighted lines with your own disk-id.
|
||||
You can find our your disk-id by executing:
|
||||
|
||||
@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
|
||||
# Direct SSH with fallback support
|
||||
internet = {
|
||||
roles.default.machines.server1 = {
|
||||
settings.address = "server1.example.com";
|
||||
settings.host = "server1.example.com";
|
||||
};
|
||||
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
|
||||
internet = {
|
||||
roles.default.machines.publicserver = {
|
||||
settings.address = "public.example.com";
|
||||
settings.host = "public.example.com";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
289
docs/site/guides/vars-advanced-examples.md
Normal file
289
docs/site/guides/vars-advanced-examples.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Advanced Vars Examples
|
||||
|
||||
This guide demonstrates complex, real-world patterns for the vars system. For basic usage, see the [Getting Started guide](vars-backend.md).
|
||||
|
||||
## Certificate Authority with Intermediate Certificates
|
||||
|
||||
This example shows how to create a complete certificate authority with root and intermediate certificates using dependencies.
|
||||
|
||||
```nix
|
||||
{
|
||||
# Generate root CA (not deployed to machines)
|
||||
clan.core.vars.generators.root-ca = {
|
||||
files."ca.key" = {
|
||||
secret = true;
|
||||
deploy = false; # Keep root key offline
|
||||
};
|
||||
files."ca.crt".secret = false;
|
||||
runtimeInputs = [ pkgs.step-cli ];
|
||||
script = ''
|
||||
step certificate create "My Root CA" \
|
||||
$out/ca.crt $out/ca.key \
|
||||
--profile root-ca \
|
||||
--no-password \
|
||||
--not-after 87600h
|
||||
'';
|
||||
};
|
||||
|
||||
# Generate intermediate key
|
||||
clan.core.vars.generators.intermediate-key = {
|
||||
files."intermediate.key" = {
|
||||
secret = true;
|
||||
deploy = true;
|
||||
};
|
||||
runtimeInputs = [ pkgs.step-cli ];
|
||||
script = ''
|
||||
step crypto keypair \
|
||||
$out/intermediate.pub \
|
||||
$out/intermediate.key \
|
||||
--no-password
|
||||
'';
|
||||
};
|
||||
|
||||
# Generate intermediate certificate signed by root
|
||||
clan.core.vars.generators.intermediate-cert = {
|
||||
files."intermediate.crt".secret = false;
|
||||
dependencies = [
|
||||
"root-ca"
|
||||
"intermediate-key"
|
||||
];
|
||||
runtimeInputs = [ pkgs.step-cli ];
|
||||
script = ''
|
||||
step certificate create "My Intermediate CA" \
|
||||
$out/intermediate.crt \
|
||||
$in/intermediate-key/intermediate.key \
|
||||
--ca $in/root-ca/ca.crt \
|
||||
--ca-key $in/root-ca/ca.key \
|
||||
--profile intermediate-ca \
|
||||
--not-after 8760h \
|
||||
--no-password
|
||||
'';
|
||||
};
|
||||
|
||||
# Use the certificates in services
|
||||
services.nginx.virtualHosts."example.com" = {
|
||||
sslCertificate = config.clan.core.vars.generators.intermediate-cert.files."intermediate.crt".value;
|
||||
sslCertificateKey = config.clan.core.vars.generators.intermediate-key.files."intermediate.key".path;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Service Secret Sharing
|
||||
|
||||
Generate secrets that multiple services can use:
|
||||
|
||||
```nix
|
||||
{
|
||||
# Generate database credentials
|
||||
clan.core.vars.generators.database = {
|
||||
share = true; # Share across machines
|
||||
files."password" = { };
|
||||
files."connection-string" = { };
|
||||
prompts.dbname = {
|
||||
description = "Database name";
|
||||
type = "line";
|
||||
};
|
||||
script = ''
|
||||
# Generate password
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > $out/password
|
||||
|
||||
# Create connection string
|
||||
echo "postgresql://app:$(cat $out/password)@localhost/$prompts/dbname" \
|
||||
> $out/connection-string
|
||||
'';
|
||||
};
|
||||
|
||||
# PostgreSQL uses the password
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
initialScript = pkgs.writeText "init.sql" ''
|
||||
CREATE USER app WITH PASSWORD '${
|
||||
builtins.readFile config.clan.core.vars.generators.database.files."password".path
|
||||
}';
|
||||
'';
|
||||
};
|
||||
|
||||
# Application uses the connection string
|
||||
systemd.services.myapp = {
|
||||
serviceConfig.EnvironmentFile =
|
||||
config.clan.core.vars.generators.database.files."connection-string".path;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## SSH Host Keys with Certificates
|
||||
|
||||
Generate SSH host keys and sign them with a CA:
|
||||
|
||||
```nix
|
||||
{
|
||||
# SSH Certificate Authority (shared)
|
||||
clan.core.vars.generators.ssh-ca = {
|
||||
share = true;
|
||||
files."ca" = { secret = true; deploy = false; };
|
||||
files."ca.pub" = { secret = false; };
|
||||
runtimeInputs = [ pkgs.openssh ];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f $out/ca
|
||||
mv $out/ca.pub $out/ca.pub
|
||||
'';
|
||||
};
|
||||
|
||||
# Host-specific SSH keys
|
||||
clan.core.vars.generators.ssh-host = {
|
||||
files."ssh_host_ed25519_key" = {
|
||||
secret = true;
|
||||
owner = "root";
|
||||
group = "root";
|
||||
mode = "0600";
|
||||
};
|
||||
files."ssh_host_ed25519_key.pub" = { secret = false; };
|
||||
files."ssh_host_ed25519_key-cert.pub" = { secret = false; };
|
||||
dependencies = [ "ssh-ca" ];
|
||||
runtimeInputs = [ pkgs.openssh ];
|
||||
script = ''
|
||||
# Generate host key
|
||||
ssh-keygen -t ed25519 -N "" -f $out/ssh_host_ed25519_key
|
||||
|
||||
# Sign with CA
|
||||
ssh-keygen -s $in/ssh-ca/ca \
|
||||
-I "host:${config.networking.hostName}" \
|
||||
-h \
|
||||
-V -5m:+365d \
|
||||
$out/ssh_host_ed25519_key.pub
|
||||
'';
|
||||
};
|
||||
|
||||
# Configure SSH to use the generated keys
|
||||
services.openssh = {
|
||||
hostKeys = [{
|
||||
path = config.clan.core.vars.generators.ssh-host.files."ssh_host_ed25519_key".path;
|
||||
type = "ed25519";
|
||||
}];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## WireGuard Mesh Network
|
||||
|
||||
Create a WireGuard configuration with pre-shared keys:
|
||||
|
||||
```nix
|
||||
{
|
||||
# Generate WireGuard keys for this host
|
||||
clan.core.vars.generators.wireguard = {
|
||||
files."privatekey" = {
|
||||
secret = true;
|
||||
owner = "systemd-network";
|
||||
mode = "0400";
|
||||
};
|
||||
files."publickey" = { secret = false; };
|
||||
files."preshared" = { secret = true; };
|
||||
runtimeInputs = [ pkgs.wireguard-tools ];
|
||||
script = ''
|
||||
# Generate key pair
|
||||
wg genkey > $out/privatekey
|
||||
wg pubkey < $out/privatekey > $out/publickey
|
||||
|
||||
# Generate pre-shared key
|
||||
wg genpsk > $out/preshared
|
||||
'';
|
||||
};
|
||||
|
||||
# Configure WireGuard
|
||||
networking.wireguard.interfaces.wg0 = {
|
||||
privateKeyFile = config.clan.core.vars.generators.wireguard.files."privatekey".path;
|
||||
|
||||
peers = [{
|
||||
publicKey = "peer-public-key-here";
|
||||
presharedKeyFile = config.clan.core.vars.generators.wireguard.files."preshared".path;
|
||||
allowedIPs = [ "10.0.0.2/32" ];
|
||||
}];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Generation Based on Machine Role
|
||||
|
||||
Generate different secrets based on machine configuration:
|
||||
|
||||
```nix
|
||||
{
|
||||
clan.core.vars.generators = lib.mkMerge [
|
||||
# All machines get basic auth
|
||||
{
|
||||
basic-auth = {
|
||||
files."htpasswd" = { };
|
||||
prompts.username = {
|
||||
description = "Username for basic auth";
|
||||
type = "line";
|
||||
};
|
||||
prompts.password = {
|
||||
description = "Password for basic auth";
|
||||
type = "hidden";
|
||||
};
|
||||
runtimeInputs = [ pkgs.apacheHttpd ];
|
||||
script = ''
|
||||
htpasswd -nbB "$prompts/username" "$prompts/password" > $out/htpasswd
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
# Only servers get API tokens
|
||||
(lib.mkIf config.services.myapi.enable {
|
||||
api-tokens = {
|
||||
files."admin-token" = { };
|
||||
files."readonly-token" = { };
|
||||
runtimeInputs = [ pkgs.openssl ];
|
||||
script = ''
|
||||
openssl rand -hex 32 > $out/admin-token
|
||||
openssl rand -hex 16 > $out/readonly-token
|
||||
'';
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Backup Encryption Keys
|
||||
|
||||
Generate and manage backup encryption keys:
|
||||
|
||||
```nix
|
||||
{
|
||||
clan.core.vars.generators.backup = {
|
||||
share = true; # Same key for all backup sources
|
||||
files."encryption.key" = {
|
||||
secret = true;
|
||||
deploy = true;
|
||||
};
|
||||
files."encryption.pub" = { secret = false; };
|
||||
runtimeInputs = [ pkgs.age ];
|
||||
script = ''
|
||||
# Generate age key pair
|
||||
age-keygen -o $out/encryption.key 2>/dev/null
|
||||
|
||||
# Extract public key
|
||||
grep "public key:" $out/encryption.key | cut -d: -f2 | tr -d ' ' \
|
||||
> $out/encryption.pub
|
||||
'';
|
||||
};
|
||||
|
||||
# Use in backup service
|
||||
services.borgbackup.jobs.system = {
|
||||
encryption = {
|
||||
mode = "repokey-blake2";
|
||||
passCommand = "cat ${config.clan.core.vars.generators.backup.files."encryption.key".path}";
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Use dependencies** to build complex multi-stage generations
|
||||
2. **Share generators** when the same secret is needed across machines
|
||||
3. **Set appropriate permissions** for service-specific secrets
|
||||
4. **Use prompts** for user-specific values that shouldn't be generated
|
||||
5. **Combine secret and non-secret files** in the same generator when they're related
|
||||
6. **Use conditional generation** with `lib.mkIf` for role-specific secrets
|
||||
155
docs/site/guides/vars-backend.md
Normal file
155
docs/site/guides/vars-backend.md
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
!!! Note
|
||||
This guide demonstrates the vars system for managing secrets and generated files
|
||||
|
||||
|
||||
Defining a linux user's password via the nixos configuration previously required running `mkpasswd ...` and then copying the hash back into the nix configuration.
|
||||
|
||||
In this example, we will guide you through automating that interaction using clan `vars`.
|
||||
|
||||
For architectural concepts and design principles, see the [Concepts guide](vars-concepts.md). For the complete API reference, see the [vars module documentation](../reference/clan.core/vars.md).
|
||||
|
||||
This guide assumes
|
||||
- Clan is set up already (see [Getting Started](../guides/getting-started/index.md))
|
||||
- a machine has been added to the clan (see [Adding Machines](getting-started/add-machines.md))
|
||||
|
||||
This section will walk you through the following steps:
|
||||
|
||||
1. declare a `generator` in the machine's nixos configuration
|
||||
2. inspect the status via the Clan CLI
|
||||
3. generate the vars
|
||||
4. observe the changes
|
||||
5. update the machine
|
||||
6. share the root password between machines
|
||||
7. change the password
|
||||
|
||||
## Declare the generator
|
||||
|
||||
In this example, a `vars` `generator` is used to:
|
||||
|
||||
- prompt the user for the password
|
||||
- run the required `mkpasswd` command to generate the hash
|
||||
- store the hash in a file
|
||||
- expose the file path to the nixos configuration
|
||||
|
||||
Create a new nix file `root-password.nix` with the following content and import it into your `configuration.nix`
|
||||
```nix
|
||||
{config, pkgs, ...}: {
|
||||
|
||||
clan.core.vars.generators.root-password = {
|
||||
# prompt the user for a password
|
||||
# (`password-input` being an arbitrary name)
|
||||
prompts.password-input.description = "the root user's password";
|
||||
prompts.password-input.type = "hidden";
|
||||
# don't store the prompted password itself
|
||||
prompts.password-input.persist = false;
|
||||
# define an output file for storing the hash
|
||||
files.password-hash.secret = false;
|
||||
# define the logic for generating the hash
|
||||
script = ''
|
||||
cat $prompts/password-input | mkpasswd -m sha-512 > $out/password-hash
|
||||
'';
|
||||
# the tools required by the script
|
||||
runtimeInputs = [ pkgs.mkpasswd ];
|
||||
};
|
||||
|
||||
# ensure users are immutable (otherwise the following config might be ignored)
|
||||
users.mutableUsers = false;
|
||||
# set the root password to the file containing the hash
|
||||
users.users.root.hashedPasswordFile =
|
||||
# clan will make sure, this path exists
|
||||
config.clan.core.vars.generators.root-password.files.password-hash.path;
|
||||
}
|
||||
```
|
||||
|
||||
## Inspect the status
|
||||
|
||||
Executing `clan vars list`, you should see the following:
|
||||
```shellSession
|
||||
$ clan vars list my_machine
|
||||
root-password/password-hash: <not set>
|
||||
```
|
||||
|
||||
...indicating that the value `password-hash` for the generator `root-password` is not set yet.
|
||||
|
||||
## Generate the values
|
||||
|
||||
This step is not strictly necessary, as deploying the machine via `clan machines update` would trigger the generator as well.
|
||||
|
||||
To run the generator, execute `clan vars generate` for your machine
|
||||
```shellSession
|
||||
$ clan vars generate my_machine
|
||||
Enter the value for root-password/password-input (hidden):
|
||||
```
|
||||
|
||||
After entering the value, the updated status is reported:
|
||||
```shellSession
|
||||
Updated var root-password/password-hash
|
||||
old: <not set>
|
||||
new: $6$RMats/YMeypFtcYX$DUi...
|
||||
```
|
||||
|
||||
## Observe the changes
|
||||
|
||||
With the last step, a new file was created in your repository:
|
||||
`vars/per-machine/my-machine/root-password/password-hash/value`
|
||||
|
||||
If the repository is a git repository, a commit was created automatically:
|
||||
```shellSession
|
||||
$ git log -n1
|
||||
commit ... (HEAD -> master)
|
||||
Author: ...
|
||||
Date: ...
|
||||
|
||||
Update vars via generator root-password for machine grmpf-nix
|
||||
```
|
||||
|
||||
## Update the machine
|
||||
|
||||
```shell
|
||||
clan machines update my_machine
|
||||
```
|
||||
|
||||
## Share root password between machines
|
||||
|
||||
If we just imported the `root-password.nix` from above into more machines, clan would ask for a new password for each additional machine.
|
||||
|
||||
If the root password instead should only be entered once and shared across all machines, the generator defined above needs to be declared as `shared`, by adding `share = true` to it:
|
||||
```nix
|
||||
{config, pkgs, ...}: {
|
||||
clan.core.vars.generators.root-password = {
|
||||
share = true;
|
||||
# ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Importing that shared generator into each machine, will ensure that the password is only asked once the first machine gets updated and then re-used for all subsequent machines.
|
||||
|
||||
## Change the root password
|
||||
|
||||
Changing the password can be done via this command.
|
||||
Replace `my-machine` with your machine.
|
||||
If the password is shared, just pick any machine that has the generator declared.
|
||||
|
||||
```shellSession
|
||||
$ clan vars generate my-machine --generator root-password --regenerate
|
||||
...
|
||||
Enter the value for root-password/password-input (hidden):
|
||||
Input received. Processing...
|
||||
...
|
||||
Updated var root-password/password-hash
|
||||
old: $6$tb27m6EOdff.X9TM$19N...
|
||||
|
||||
new: $6$OyoQtDVzeemgh8EQ$zRK...
|
||||
```
|
||||
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Understanding Vars Concepts](vars-concepts.md) - Learn about the architecture and core concepts
|
||||
- [Advanced Examples](vars-advanced-examples.md) - Complex real-world examples including certificates, SSH keys, and more
|
||||
- [Troubleshooting Guide](vars-troubleshooting.md) - Common issues and solutions
|
||||
- [Migration Guide](migrations/migration-facts-vars.md) - Migrate from legacy facts system
|
||||
- [Reference Documentation for `clan.core.vars` NixOS options](../reference/clan.core/vars.md)
|
||||
- [Reference Documentation for the `clan vars` CLI command](../reference/cli/vars.md)
|
||||
129
docs/site/guides/vars-concepts.md
Normal file
129
docs/site/guides/vars-concepts.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Understanding Clan Vars - Concepts & Architecture
|
||||
|
||||
This guide explains the architecture and design principles behind the vars system. For a hands-on tutorial, see the [Getting Started guide](vars-backend.md).
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The vars system provides a declarative, reproducible way to manage generated files (especially secrets) in NixOS configurations.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Generator Script] --> B[Output Files]
|
||||
C[User Prompts] --> A
|
||||
D[Dependencies] --> A
|
||||
B --> E[Secret Storage<br/>sops/password-store]
|
||||
B --> F[Nix Store<br/>public files]
|
||||
E --> G[Machine Deployment]
|
||||
F --> G
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
### 1. Declarative Generation
|
||||
|
||||
Unlike imperative secret management, vars are declared in your NixOS configuration and generated deterministically. This ensures reproducibility across deployments.
|
||||
|
||||
### 2. Separation of Concerns
|
||||
|
||||
- **Generation logic**: Defined in generator scripts
|
||||
- **Storage**: Handled by pluggable backends (sops, password-store, etc.)
|
||||
- **Deployment**: Managed by NixOS activation scripts
|
||||
- **Access control**: Enforced through file permissions and ownership
|
||||
|
||||
### 3. Composability Through Dependencies
|
||||
|
||||
Generators can depend on outputs from other generators, enabling complex workflows:
|
||||
|
||||
```nix
|
||||
# Dependencies create a directed acyclic graph (DAG)
|
||||
A → B → C
|
||||
↓
|
||||
D
|
||||
```
|
||||
|
||||
This allows building sophisticated systems like certificate authorities where intermediate certificates depend on root certificates.
|
||||
|
||||
### 4. Type Safety
|
||||
|
||||
The vars system distinguishes between:
|
||||
- **Secret files**: Only accessible via `.path`, deployed to `/run/secrets/`
|
||||
- **Public files**: Accessible via `.value`, stored in nix store
|
||||
|
||||
This prevents accidental exposure of secrets in the nix store.
|
||||
|
||||
## Storage Backend Architecture
|
||||
|
||||
The vars system uses pluggable storage backends:
|
||||
|
||||
- **sops** (default): Integrates with clan's existing sops encryption
|
||||
- **password-store**: For users already using pass
|
||||
|
||||
Each backend handles encryption/decryption transparently, allowing the same generator definitions to work across different security models.
|
||||
|
||||
## Timing and Lifecycle
|
||||
|
||||
### Generation Phases
|
||||
|
||||
1. **Pre-deployment**: `clan vars generate` creates vars before deployment
|
||||
2. **During deployment**: Missing vars are generated automatically
|
||||
3. **Regeneration**: Explicit regeneration with `--regenerate` flag
|
||||
|
||||
### The `neededFor` Option
|
||||
|
||||
Control when vars are available during system activation:
|
||||
|
||||
```nix
|
||||
files."early-secret" = {
|
||||
secret = true;
|
||||
neededFor = [ "users" "groups" ]; # Available early in activation
|
||||
};
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Multi-Machine Coordination
|
||||
|
||||
The `share` option enables cross-machine secret sharing:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Shared Generator] --> B[Machine 1]
|
||||
A --> C[Machine 2]
|
||||
A --> D[Machine 3]
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- Shared certificate authorities
|
||||
- Mesh VPN pre-shared keys
|
||||
- Cluster join tokens
|
||||
|
||||
### Generator Composition
|
||||
|
||||
Complex systems can be built by composing simple generators:
|
||||
|
||||
```
|
||||
root-ca → intermediate-ca → service-cert
|
||||
↓
|
||||
ocsp-responder
|
||||
```
|
||||
|
||||
Each generator focuses on one task, making the system modular and testable.
|
||||
|
||||
## Key Advantages
|
||||
|
||||
Compared to manual secret management, vars provides:
|
||||
|
||||
- **Declarative configuration**: Define once, generate consistently
|
||||
- **Dependency management**: Build complex systems with generator dependencies
|
||||
- **Type safety**: Separate handling of secret and public files
|
||||
- **User prompts**: Gather input when needed
|
||||
- **Easy regeneration**: Update secrets with a single command
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Practical Tutorial](vars-backend.md) - Step-by-step guide
|
||||
- [Advanced Examples](vars-advanced-examples.md) - Real-world patterns
|
||||
- [Troubleshooting](vars-troubleshooting.md) - Common issues
|
||||
- [Reference](../reference/clan.core/vars.md) - Complete API
|
||||
169
docs/site/guides/vars-overview.md
Normal file
169
docs/site/guides/vars-overview.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Vars System Overview
|
||||
|
||||
The vars system is clan's declarative solution for managing generated files, secrets, and dynamic configuration in your NixOS deployments. It eliminates the manual steps of generating credentials, certificates, and other dynamic values by automating these processes within your infrastructure-as-code workflow.
|
||||
|
||||
## What Problems Does Vars Solve?
|
||||
|
||||
### Before Vars: Manual Secret Management
|
||||
|
||||
Traditional NixOS deployments require manual steps for secrets and generated files:
|
||||
|
||||
```bash
|
||||
# Generate password hash manually
|
||||
mkpasswd -m sha-512 > /tmp/root-password-hash
|
||||
# Copy hash into configuration
|
||||
users.users.root.hashedPasswordFile = "/tmp/root-password-hash";
|
||||
```
|
||||
|
||||
This approach has several problems:
|
||||
- **Not reproducible**: Manual steps vary between team members
|
||||
- **Hard to maintain**: Updating secrets requires remembering manual commands
|
||||
- **Deployment friction**: Secrets must be managed outside of your configuration
|
||||
- **Team collaboration issues**: Sharing credentials securely is complex
|
||||
|
||||
### After Vars: Declarative Generation
|
||||
|
||||
With vars, the same process becomes declarative and automated:
|
||||
|
||||
```nix
|
||||
clan.core.vars.generators.root-password = {
|
||||
prompts.password.description = "Root password";
|
||||
prompts.password.type = "hidden";
|
||||
files.hash.secret = false;
|
||||
script = "mkpasswd -m sha-512 < $prompts/password > $out/hash";
|
||||
runtimeInputs = [ pkgs.mkpasswd ];
|
||||
};
|
||||
|
||||
users.users.root.hashedPasswordFile =
|
||||
config.clan.core.vars.generators.root-password.files.hash.path;
|
||||
```
|
||||
|
||||
## Core Benefits
|
||||
|
||||
- **🔄 Reproducible**: Same inputs always produce the same outputs
|
||||
- **📝 Declarative**: Defined alongside your NixOS configuration
|
||||
- **🔐 Secure**: Automatic secret storage and encrypted deployment
|
||||
- **👥 Collaborative**: Built-in sharing for team environments
|
||||
- **🚀 Automated**: No manual intervention required for deployments
|
||||
- **🔗 Integrated**: Works seamlessly with clan's deployment workflow
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Generator Declaration] --> B[clan vars generate]
|
||||
B --> C{Prompts User}
|
||||
C --> D[Execute Script]
|
||||
D --> E[Output Files]
|
||||
E --> F{Secret?}
|
||||
F -->|Yes| G[Encrypted Storage]
|
||||
F -->|No| H[Git Repository]
|
||||
G --> I[Deploy to Machine]
|
||||
H --> I
|
||||
I --> J[Available in NixOS]
|
||||
```
|
||||
|
||||
1. **Declare generators** in your NixOS configuration
|
||||
2. **Generate values** using `clan vars generate` (or automatically during deployment)
|
||||
3. **Store securely** in encrypted backends or version control
|
||||
4. **Deploy seamlessly** to your machines where they're accessible as file paths
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
| Use Case | What Gets Generated | Benefits |
|
||||
|----------|-------------------|----------|
|
||||
| **User passwords** | Password hashes | No plaintext in config |
|
||||
| **SSH keys** | Host/user keypairs | Automated key rotation |
|
||||
| **TLS certificates** | Certificates + private keys | Automated PKI |
|
||||
| **Database credentials** | Passwords + connection strings | Secure service communication |
|
||||
| **API tokens** | Random tokens | Service authentication |
|
||||
| **Configuration files** | Complex configs with secrets | Dynamic config generation |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The vars system has three main components:
|
||||
|
||||
### 1. **Generators**
|
||||
Define how to create files from inputs:
|
||||
- **Prompts**: Values requested from users
|
||||
- **Scripts**: Generation logic
|
||||
- **Dependencies**: Other generators this depends on
|
||||
- **Outputs**: Files that get created
|
||||
|
||||
### 2. **Storage Backends**
|
||||
Handle secret storage and deployment:
|
||||
- **sops**: Encrypted files in git (recommended)
|
||||
- **password-store**: GPG/age-based secret storage
|
||||
- **vm**: For development/testing
|
||||
|
||||
### 3. **Integration**
|
||||
Seamless NixOS integration:
|
||||
- File paths available at build time
|
||||
- Automatic deployment to machines
|
||||
- Service restarts on changes
|
||||
|
||||
## Learning Path
|
||||
|
||||
Ready to get started? Follow this recommended path:
|
||||
|
||||
### 1. **🚀 Hands-On Tutorial**
|
||||
[Vars Getting Started Guide](vars-backend.md)
|
||||
Start here for a practical walkthrough with password generation.
|
||||
|
||||
### 2. **🏗️ Understand the Design**
|
||||
[Vars Concepts & Architecture](vars-concepts.md)
|
||||
Deep dive into design principles and advanced patterns.
|
||||
|
||||
### 3. **💡 Real-World Examples**
|
||||
[Advanced Examples](vars-advanced-examples.md)
|
||||
Complex scenarios including certificates, SSH keys, and databases.
|
||||
|
||||
### 4. **🔧 Troubleshooting**
|
||||
[Troubleshooting Guide](vars-troubleshooting.md)
|
||||
Solutions for common issues and debugging techniques.
|
||||
|
||||
### 5. **📚 Complete Reference**
|
||||
- [NixOS Module Options](../reference/clan.core/vars.md)
|
||||
- [CLI Commands](../reference/cli/vars.md)
|
||||
|
||||
## Quick Start Example
|
||||
|
||||
Here's a complete example showing password generation and usage:
|
||||
|
||||
```nix
|
||||
# generator.nix
|
||||
{ config, pkgs, ... }: {
|
||||
clan.core.vars.generators.user-password = {
|
||||
prompts.password = {
|
||||
description = "User password";
|
||||
type = "hidden";
|
||||
};
|
||||
files.hash = { secret = false; };
|
||||
script = ''
|
||||
mkpasswd -m sha-512 < $prompts/password > $out/hash
|
||||
'';
|
||||
runtimeInputs = [ pkgs.mkpasswd ];
|
||||
};
|
||||
|
||||
users.users.myuser = {
|
||||
hashedPasswordFile =
|
||||
config.clan.core.vars.generators.user-password.files.hash.path;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Generate the password
|
||||
clan vars generate my-machine
|
||||
|
||||
# Deploy to machine
|
||||
clan machines update my-machine
|
||||
```
|
||||
|
||||
## Migration from Facts
|
||||
|
||||
If you're currently using the legacy facts system, see our [Migration Guide](migrations/migration-facts-vars.md) for step-by-step instructions on upgrading to vars.
|
||||
|
||||
---
|
||||
|
||||
**Ready to start?** Head to the [Getting Started Guide](vars-backend.md) for your first hands-on experience with the vars system.
|
||||
266
docs/site/guides/vars-troubleshooting.md
Normal file
266
docs/site/guides/vars-troubleshooting.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Troubleshooting Vars
|
||||
|
||||
Quick reference for diagnosing and fixing vars issues. For basic usage, see the [Getting Started guide](vars-backend.md).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Generator Script Fails
|
||||
|
||||
**Symptom**: Error during `clan vars generate` or deployment
|
||||
|
||||
**Possible causes and solutions**:
|
||||
|
||||
1. **Missing runtime inputs**
|
||||
```nix
|
||||
# Wrong - missing required tool
|
||||
runtimeInputs = [ ];
|
||||
script = ''
|
||||
openssl rand -hex 32 > $out/secret # openssl not found!
|
||||
'';
|
||||
|
||||
# Correct
|
||||
runtimeInputs = [ pkgs.openssl ];
|
||||
```
|
||||
|
||||
2. **Wrong output path**
|
||||
```nix
|
||||
# Wrong - must use $out
|
||||
script = ''
|
||||
echo "secret" > ./myfile
|
||||
'';
|
||||
|
||||
# Correct
|
||||
script = ''
|
||||
echo "secret" > $out/myfile
|
||||
'';
|
||||
```
|
||||
|
||||
3. **Missing declared files**
|
||||
```nix
|
||||
files."config" = { };
|
||||
files."key" = { };
|
||||
script = ''
|
||||
# Wrong - only generates one file
|
||||
echo "data" > $out/config
|
||||
'';
|
||||
|
||||
# Correct - must generate all declared files
|
||||
script = ''
|
||||
echo "data" > $out/config
|
||||
echo "key" > $out/key
|
||||
'';
|
||||
```
|
||||
|
||||
### Cannot Access Generated Files
|
||||
|
||||
**Symptom**: "attribute 'value' missing" or file not found
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Secret files don't have `.value`**
|
||||
```nix
|
||||
# Wrong - secret files can't use .value
|
||||
files."secret" = { secret = true; };
|
||||
# ...
|
||||
environment.etc."app.conf".text =
|
||||
config.clan.core.vars.generators.app.files."secret".value;
|
||||
|
||||
# Correct - use .path for secrets
|
||||
environment.etc."app.conf".source =
|
||||
config.clan.core.vars.generators.app.files."secret".path;
|
||||
```
|
||||
|
||||
2. **Public files should use `.value`**
|
||||
```nix
|
||||
# Better for non-secrets
|
||||
files."cert.pem" = { secret = false; };
|
||||
# ...
|
||||
sslCertificate =
|
||||
config.clan.core.vars.generators.ca.files."cert.pem".value;
|
||||
```
|
||||
|
||||
### Dependencies Not Available
|
||||
|
||||
**Symptom**: "No such file or directory" when accessing `$in/...`
|
||||
|
||||
**Solution**: Declare dependencies correctly
|
||||
```nix
|
||||
clan.core.vars.generators.child = {
|
||||
# Wrong - missing dependency
|
||||
script = ''
|
||||
cat $in/parent/file > $out/newfile
|
||||
'';
|
||||
|
||||
# Correct
|
||||
dependencies = [ "parent" ];
|
||||
script = ''
|
||||
cat $in/parent/file > $out/newfile
|
||||
'';
|
||||
};
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
**Symptom**: Service cannot read generated secret file
|
||||
|
||||
**Solution**: Set correct ownership and permissions
|
||||
```nix
|
||||
files."service.key" = {
|
||||
secret = true;
|
||||
owner = "myservice"; # Match service user
|
||||
group = "myservice";
|
||||
mode = "0400"; # Read-only for owner
|
||||
};
|
||||
```
|
||||
|
||||
### Vars Not Regenerating
|
||||
|
||||
**Symptom**: Changes to generator script don't trigger regeneration
|
||||
|
||||
**Solution**: Use `--regenerate` flag
|
||||
```bash
|
||||
clan vars generate my-machine --generator my-generator --regenerate
|
||||
```
|
||||
|
||||
### Prompts Not Working
|
||||
|
||||
**Symptom**: Script fails with "No such file or directory" for prompts
|
||||
|
||||
**Solution**: Access prompts correctly
|
||||
```nix
|
||||
# Wrong
|
||||
script = ''
|
||||
echo $password > $out/file
|
||||
'';
|
||||
|
||||
# Correct
|
||||
prompts.password.type = "hidden";
|
||||
script = ''
|
||||
cat $prompts/password > $out/file
|
||||
'';
|
||||
```
|
||||
|
||||
## Debugging Techniques
|
||||
|
||||
### 1. Check Generator Status
|
||||
|
||||
See what vars are set:
|
||||
```bash
|
||||
clan vars list my-machine
|
||||
```
|
||||
|
||||
### 2. Inspect Generated Files
|
||||
|
||||
For shared vars:
|
||||
```bash
|
||||
ls -la vars/shared/my-generator/
|
||||
```
|
||||
|
||||
For per-machine vars:
|
||||
```bash
|
||||
ls -la vars/per-machine/my-machine/my-generator/
|
||||
```
|
||||
|
||||
### 3. Test Generators Locally
|
||||
|
||||
Create a test script to debug:
|
||||
```nix
|
||||
# test-generator.nix
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.stdenv.mkDerivation {
|
||||
name = "test-generator";
|
||||
buildInputs = [ pkgs.openssl ]; # Your runtime inputs
|
||||
buildCommand = ''
|
||||
# Your generator script here
|
||||
mkdir -p $out
|
||||
openssl rand -hex 32 > $out/secret
|
||||
ls -la $out/
|
||||
'';
|
||||
}
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
nix-build test-generator.nix
|
||||
```
|
||||
|
||||
### 4. Enable Debug Logging
|
||||
|
||||
Set debug mode:
|
||||
```bash
|
||||
clan --debug vars generate my-machine
|
||||
```
|
||||
|
||||
### 5. Check File Permissions
|
||||
|
||||
Verify generated secret permissions:
|
||||
```bash
|
||||
# On the target machine
|
||||
ls -la /run/secrets/
|
||||
```
|
||||
|
||||
## Recovery Procedures
|
||||
|
||||
### Regenerate All Vars
|
||||
|
||||
If vars are corrupted or need refresh:
|
||||
```bash
|
||||
# Regenerate all for a machine
|
||||
clan vars generate my-machine --regenerate
|
||||
|
||||
# Regenerate specific generator
|
||||
clan vars generate my-machine --generator my-generator --regenerate
|
||||
```
|
||||
|
||||
### Manual Secret Injection
|
||||
|
||||
For recovery or testing:
|
||||
```bash
|
||||
# Set a var manually (bypass generator)
|
||||
echo "temporary-secret" | clan vars set my-machine my-generator/my-file
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
Vars are stored in the repository:
|
||||
```bash
|
||||
# Restore previous version
|
||||
git checkout HEAD~1 -- vars/
|
||||
|
||||
# Check and regenerate if needed
|
||||
clan vars list my-machine
|
||||
```
|
||||
|
||||
## Storage Backend Issues
|
||||
|
||||
### SOPS Decryption Fails
|
||||
|
||||
**Symptom**: "Failed to decrypt" or permission errors
|
||||
|
||||
**Solution**: Ensure your user/machine has the correct age keys configured. Clan manages encryption keys automatically based on the configured users and machines in your flake.
|
||||
|
||||
Check that:
|
||||
1. Your machine is properly configured in the flake
|
||||
2. Your user has access to the machine's secrets
|
||||
3. The age key is available in the expected location
|
||||
|
||||
### Password Store Issues
|
||||
|
||||
**Symptom**: "pass: store not initialized"
|
||||
|
||||
**Solution**: Initialize password store:
|
||||
```bash
|
||||
export PASSWORD_STORE_DIR=/path/to/clan/vars
|
||||
pass init your-gpg-key
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If these solutions don't resolve your issue:
|
||||
|
||||
1. Check the [clan-core issue tracker](https://git.clan.lol/clan/clan-core/issues)
|
||||
2. Ask in the Clan community channels
|
||||
3. Provide:
|
||||
- The generator configuration
|
||||
- The exact error message
|
||||
- Output of `clan --debug vars generate`
|
||||
26
flake.lock
generated
26
flake.lock
generated
@@ -13,11 +13,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756695982,
|
||||
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=",
|
||||
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb",
|
||||
"lastModified": 1757300813,
|
||||
"narHash": "sha256-JYQl+8nJYImg/inqotu9nEPcTXrRJixFN6sOfn6Tics=",
|
||||
"rev": "b5f2157bcd26c73551374cd6e5b027b0119b2f3d",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/cc8f26e7e6c2dc985526ba59b286ae5a83168cdb.tar.gz"
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/b5f2157bcd26c73551374cd6e5b027b0119b2f3d.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -31,11 +31,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756115622,
|
||||
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
|
||||
"lastModified": 1757255839,
|
||||
"narHash": "sha256-XH33B1X888Xc/xEXhF1RPq/kzKElM0D5C9N6YdvOvIc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
|
||||
"rev": "c8a0e78d86b12ea67be6ed0f7cae7f9bfabae75a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -51,11 +51,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1754487366,
|
||||
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
|
||||
"lastModified": 1756770412,
|
||||
"narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
|
||||
"rev": "4524271976b625a4a605beefd893f270620fd751",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -71,11 +71,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755825449,
|
||||
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
|
||||
"lastModified": 1757430124,
|
||||
"narHash": "sha256-MhDltfXesGH8VkGv3hmJ1QEKl1ChTIj9wmGAFfWj/Wk=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
|
||||
"rev": "830b3f0b50045cf0bcfd4dab65fad05bf882e196",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -87,6 +87,8 @@ in
|
||||
relativeDir = removePrefix "${self}/" (toString config.clan.directory);
|
||||
|
||||
update-vars = hostPkgs.writeShellScriptBin "update-vars" ''
|
||||
set -x
|
||||
export PRJ_ROOT=$(git rev-parse --show-toplevel)
|
||||
${update-vars-script} $PRJ_ROOT/${relativeDir} ${testName}
|
||||
'';
|
||||
|
||||
|
||||
@@ -268,8 +268,14 @@ class Machine:
|
||||
)
|
||||
|
||||
def nsenter_command(self, command: str) -> list[str]:
|
||||
nsenter = shutil.which("nsenter")
|
||||
|
||||
if not nsenter:
|
||||
msg = "nsenter command not found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return [
|
||||
"nsenter",
|
||||
nsenter,
|
||||
"--target",
|
||||
str(self.container_pid),
|
||||
"--mount",
|
||||
@@ -326,6 +332,7 @@ class Machine:
|
||||
|
||||
return subprocess.run(
|
||||
self.nsenter_command(command),
|
||||
env={},
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
|
||||
@@ -189,8 +189,12 @@ in
|
||||
clan.core.vars.generators.zerotier = {
|
||||
migrateFact = "zerotier";
|
||||
files.zerotier-ip.secret = false;
|
||||
files.zerotier-ip.restartUnits = [ "zerotierone.service" ];
|
||||
files.zerotier-network-id.secret = false;
|
||||
files.zerotier-identity-secret = { };
|
||||
files.zerotier-network-id.restartUnits = [ "zerotierone.service" ];
|
||||
files.zerotier-identity-secret = {
|
||||
restartUnits = [ "zerotierone.service" ];
|
||||
};
|
||||
runtimeInputs = [
|
||||
config.services.zerotierone.package
|
||||
pkgs.python3
|
||||
@@ -211,7 +215,10 @@ in
|
||||
clan.core.vars.generators.zerotier = {
|
||||
migrateFact = "zerotier";
|
||||
files.zerotier-ip.secret = false;
|
||||
files.zerotier-identity-secret = { };
|
||||
files.zerotier-ip.restartUnits = [ "zerotierone.service" ];
|
||||
files.zerotier-identity-secret = {
|
||||
restartUnits = [ "zerotierone.service" ];
|
||||
};
|
||||
runtimeInputs = [
|
||||
config.services.zerotierone.package
|
||||
pkgs.python3
|
||||
|
||||
@@ -109,6 +109,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
title="Clan App",
|
||||
size=Size(1280, 1024, SizeHint.NONE),
|
||||
shared_threads=shared_threads,
|
||||
app_id="org.clan.app",
|
||||
)
|
||||
|
||||
API.overwrite_fn(get_system_file)
|
||||
|
||||
@@ -5,6 +5,11 @@ import platform
|
||||
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
|
||||
from pathlib import Path
|
||||
|
||||
# Native handle kinds
|
||||
WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW = 0
|
||||
WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET = 1
|
||||
WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER = 2
|
||||
|
||||
|
||||
def _encode_c_string(s: str) -> bytes:
|
||||
return s.encode("utf-8")
|
||||
@@ -72,6 +77,10 @@ class _WebviewLibrary:
|
||||
self.webview_create.argtypes = [c_int, c_void_p]
|
||||
self.webview_create.restype = c_void_p
|
||||
|
||||
self.webview_create_with_app_id = self.lib.webview_create_with_app_id
|
||||
self.webview_create_with_app_id.argtypes = [c_int, c_void_p, c_char_p]
|
||||
self.webview_create_with_app_id.restype = c_void_p
|
||||
|
||||
self.webview_destroy = self.lib.webview_destroy
|
||||
self.webview_destroy.argtypes = [c_void_p]
|
||||
|
||||
@@ -105,6 +114,10 @@ class _WebviewLibrary:
|
||||
self.webview_return = self.lib.webview_return
|
||||
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
|
||||
|
||||
self.webview_get_native_handle = self.lib.webview_get_native_handle
|
||||
self.webview_get_native_handle.argtypes = [c_void_p, c_int]
|
||||
self.webview_get_native_handle.restype = c_void_p
|
||||
|
||||
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
|
||||
|
||||
self.CFUNCTYPE = CFUNCTYPE
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
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.tasks import WebThread
|
||||
|
||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||
from ._webview_ffi import (
|
||||
_encode_c_string,
|
||||
_webview_lib,
|
||||
)
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -32,6 +36,21 @@ class FuncStatus(IntEnum):
|
||||
FAILURE = 1
|
||||
|
||||
|
||||
class NativeHandleKind(IntEnum):
|
||||
# Top-level window. @c GtkWindow pointer (GTK), @c NSWindow pointer (Cocoa)
|
||||
# or @c HWND (Win32)
|
||||
UI_WINDOW = 0
|
||||
|
||||
# Browser widget. @c GtkWidget pointer (GTK), @c NSView pointer (Cocoa) or
|
||||
# @c HWND (Win32).
|
||||
UI_WIDGET = 1
|
||||
|
||||
# Browser controller. @c WebKitWebView pointer (WebKitGTK), @c WKWebView
|
||||
# pointer (Cocoa/WebKit) or @c ICoreWebView2Controller pointer
|
||||
# (Win32/WebView2).
|
||||
BROWSER_CONTROLLER = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Size:
|
||||
width: int
|
||||
@@ -46,6 +65,7 @@ class Webview:
|
||||
size: Size | None = None
|
||||
window: int | None = None
|
||||
shared_threads: dict[str, WebThread] | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
# initialized later
|
||||
_bridge: WebviewBridge | None = None
|
||||
@@ -56,7 +76,14 @@ class Webview:
|
||||
def _create_handle(self) -> None:
|
||||
# Initialize the webview handle
|
||||
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]] = {}
|
||||
|
||||
# Since we can't use object.__setattr__, we'll initialize differently
|
||||
@@ -217,6 +244,21 @@ class Webview:
|
||||
self._callbacks[name] = c_callback
|
||||
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
|
||||
|
||||
def get_native_handle(
|
||||
self, kind: NativeHandleKind = NativeHandleKind.UI_WINDOW
|
||||
) -> int | None:
|
||||
"""Get the native handle (platform-dependent).
|
||||
|
||||
Args:
|
||||
kind: Handle kind - NativeHandleKind enum value
|
||||
|
||||
Returns:
|
||||
Native handle as integer, or None if failed
|
||||
|
||||
"""
|
||||
handle = _webview_lib.webview_get_native_handle(self.handle, kind.value)
|
||||
return handle if handle else None
|
||||
|
||||
def unbind(self, name: str) -> None:
|
||||
if name in self._callbacks:
|
||||
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
gobject-introspection,
|
||||
gtk4,
|
||||
lib,
|
||||
stdenv,
|
||||
# macOS-specific dependencies
|
||||
imagemagick,
|
||||
makeWrapper,
|
||||
libicns,
|
||||
}:
|
||||
let
|
||||
source =
|
||||
@@ -91,7 +96,12 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
# gtk4 deps
|
||||
wrapGAppsHook4
|
||||
]
|
||||
++ runtimeDependencies;
|
||||
++ runtimeDependencies
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
imagemagick
|
||||
makeWrapper
|
||||
libicns
|
||||
];
|
||||
|
||||
# The necessity of setting buildInputs and propagatedBuildInputs to the
|
||||
# same values for your Python package within Nix largely stems from ensuring
|
||||
@@ -148,16 +158,113 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
postInstall = ''
|
||||
mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
||||
cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui
|
||||
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.
|
||||
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
|
||||
postFixup = ''
|
||||
rm $out/nix-support/propagated-build-inputs
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
set -eu pipefail
|
||||
mv $out/bin/clan-app $out/bin/.clan-app-orig
|
||||
|
||||
|
||||
# Create command line wrapper that executes the app bundle
|
||||
cat > $out/bin/clan-app << EOF
|
||||
#!/bin/bash
|
||||
exec "$out/Applications/Clan App.app/Contents/MacOS/Clan App" "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/clan-app
|
||||
set +eu pipefail
|
||||
'';
|
||||
checkPhase = ''
|
||||
set -eu pipefail
|
||||
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
|
||||
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
|
||||
|
||||
@@ -171,6 +278,7 @@ pythonRuntime.pkgs.buildPythonApplication {
|
||||
fc-list
|
||||
|
||||
PYTHONPATH= $out/bin/clan-app --help
|
||||
set +eu pipefail
|
||||
'';
|
||||
desktopItems = [ desktop-file ];
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ let
|
||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
||||
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
|
||||
};
|
||||
commitMono_ttf = fetchurl {
|
||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
|
||||
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
|
||||
archivoSemi_ttf = fetchurl {
|
||||
url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf";
|
||||
hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w=";
|
||||
};
|
||||
|
||||
in
|
||||
@@ -66,5 +66,5 @@ runCommand "" { } ''
|
||||
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.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
|
||||
|
||||
|
||||
if ! command -v xdg-mime &> /dev/null; then
|
||||
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
||||
fi
|
||||
|
||||
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))')
|
||||
|
||||
if [ "$ALREADY_INSTALLED" = "true" ]; then
|
||||
@@ -14,9 +9,23 @@ else
|
||||
nix profile install .#clan-app
|
||||
fi
|
||||
|
||||
# Check OS type
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
|
||||
# install desktop file
|
||||
set -eou pipefail
|
||||
DESKTOP_FILE_NAME=org.clan.app.desktop
|
||||
if ! command -v xdg-mime &> /dev/null; then
|
||||
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
||||
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"
|
||||
export NODE_PATH="$(pwd)/node_modules"
|
||||
export PATH="$NODE_PATH/.bin:$(pwd)/bin:$PATH"
|
||||
|
||||
rm -rf .fonts || true
|
||||
cp -r ${self'.packages.fonts} .fonts
|
||||
chmod -R +w .fonts
|
||||
mkdir -p api
|
||||
|
||||
@@ -38,7 +38,7 @@ fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
|
||||
assets.forEach((asset) => {
|
||||
// console.log(asset);
|
||||
if (asset.src === "index.html") {
|
||||
asset.css.forEach((cssEntry) => {
|
||||
asset.css?.forEach((cssEntry) => {
|
||||
// css to be processed
|
||||
|
||||
const css = fs.readFileSync(`dist/${cssEntry}`, "utf8");
|
||||
|
||||
@@ -37,6 +37,11 @@ export const Menu = (props: {
|
||||
"pointer-events": "auto",
|
||||
}}
|
||||
class={styles.list}
|
||||
onContextMenu={(e) => {
|
||||
// Prevent default context menu
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<li
|
||||
class={styles.item}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fieldset {
|
||||
.fieldset {
|
||||
@apply flex flex-col w-full;
|
||||
|
||||
legend {
|
||||
|
||||
@@ -35,10 +35,10 @@ export const Fieldset = (props: FieldsetProps) => {
|
||||
: props.children;
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
<div
|
||||
role="group"
|
||||
class={cx({ inverted: props.inverted })}
|
||||
disabled={props.disabled || false}
|
||||
class={cx("fieldset", { inverted: props.inverted })}
|
||||
aria-disabled={props.disabled || undefined}
|
||||
>
|
||||
{props.legend && (
|
||||
<legend>
|
||||
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
|
||||
</Typography>
|
||||
</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 { 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 cx from "classnames";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Tag } from "@/src/components/Tag/Tag";
|
||||
|
||||
import "./MachineTags.css";
|
||||
import { Label } from "@/src/components/Form/Label";
|
||||
import { Orienter } from "@/src/components/Form/Orienter";
|
||||
import { CollectionNode } from "@kobalte/core";
|
||||
import styles from "./MachineTags.module.css";
|
||||
|
||||
export interface MachineTag {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
new?: boolean;
|
||||
}
|
||||
|
||||
export type MachineTagsProps = FieldProps & {
|
||||
name: string;
|
||||
input: ComponentProps<"select">;
|
||||
onChange: (values: string[]) => void;
|
||||
defaultValue?: string[];
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string[];
|
||||
defaultOptions?: string[];
|
||||
readonlyOptions?: string[];
|
||||
};
|
||||
@@ -44,26 +50,12 @@ const sortedOptions = (options: MachineTag[]) =>
|
||||
const sortedAndUniqueOptions = (options: MachineTag[]) =>
|
||||
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) => {
|
||||
// convert default value string[] into MachineTag[]
|
||||
const [local, rest] = splitProps(props, ["defaultValue"]);
|
||||
|
||||
// // convert default value string[] into MachineTag[]
|
||||
const defaultValue = sortedAndUniqueOptions(
|
||||
(props.defaultValue || []).map((value) => ({ value })),
|
||||
(local.defaultValue || []).map((value) => ({ value })),
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
// react when enter is pressed inside of the text input
|
||||
if (event.key === "Enter") {
|
||||
@@ -85,22 +122,49 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
|
||||
// get the current input value, exiting early if it's empty
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (input.value === "") return;
|
||||
const trimmed = input.value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setAvailableOptions((options) => {
|
||||
return options.map((option) => {
|
||||
return {
|
||||
...option,
|
||||
new: undefined,
|
||||
};
|
||||
});
|
||||
setAvailableOptions((curr) => {
|
||||
if (curr.find((option) => option.value === trimmed)) {
|
||||
return curr;
|
||||
}
|
||||
return [
|
||||
...curr,
|
||||
{
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
});
|
||||
setSelectedOptions((curr) => {
|
||||
if (curr.find((option) => option.value === trimmed)) {
|
||||
return curr;
|
||||
}
|
||||
return [
|
||||
...curr,
|
||||
{
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// reset the input value
|
||||
selectRef.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
selectRef.dispatchEvent(
|
||||
new Event("change", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
input.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Notify when selected options change
|
||||
createEffect(
|
||||
on(selectedOptions, (options) => {
|
||||
props.onChange(options.map((o) => o.value));
|
||||
}),
|
||||
);
|
||||
|
||||
const align = () => {
|
||||
if (props.readOnly) {
|
||||
return "center";
|
||||
@@ -112,41 +176,19 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
return (
|
||||
<Combobox<MachineTag>
|
||||
multiple
|
||||
class={cx("form-field", "machine-tags", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
class={cx("form-field", styles.machineTags, props.orientation)}
|
||||
{...splitProps(props, ["defaultValue"])[1]}
|
||||
defaultValue={defaultValue}
|
||||
value={selectedOptions()}
|
||||
options={availableOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="value"
|
||||
optionLabel="value"
|
||||
optionDisabled="disabled"
|
||||
itemComponent={ItemComponent}
|
||||
placeholder="Enter a tag name"
|
||||
// triggerMode="focus"
|
||||
removeOnBackspace={false}
|
||||
defaultFilter={() => true}
|
||||
onInput={(event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
// as the user types in the input box, we maintain a "new" option
|
||||
// in the list of available options
|
||||
setAvailableOptions((options) => {
|
||||
return [
|
||||
// remove the old "new" entry
|
||||
...options.filter((option) => !option.new),
|
||||
// add the updated "new" entry
|
||||
{ value: input.value, new: true },
|
||||
];
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
// clear the in-progress "new" option from the list of available options
|
||||
setAvailableOptions((options) => {
|
||||
return options.filter((option) => !option.new);
|
||||
});
|
||||
itemComponent={ItemComponent(props.inverted || false)}
|
||||
placeholder="Start typing a name and press enter"
|
||||
onChange={() => {
|
||||
// noop, we handle this via the selectedOptions signal
|
||||
}}
|
||||
>
|
||||
<Orienter orientation={props.orientation} align={align()}>
|
||||
@@ -156,11 +198,18 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
{...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) => (
|
||||
<div class="selected-options">
|
||||
<div class={styles.selectedOptions}>
|
||||
<For each={state.selectedOptions()}>
|
||||
{(option) => (
|
||||
<Tag
|
||||
@@ -177,7 +226,13 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
icon={"Close"}
|
||||
size="0.5rem"
|
||||
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>
|
||||
<Show when={!props.readOnly}>
|
||||
<div class="input-container">
|
||||
<Combobox.Input onKeyDown={onKeyDown} />
|
||||
<Combobox.Trigger class="trigger">
|
||||
<Combobox.Icon class="icon">
|
||||
<Icon
|
||||
icon="Expand"
|
||||
inverted={!props.inverted}
|
||||
size="100%"
|
||||
/>
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
<Combobox.Trigger class={styles.trigger}>
|
||||
<Icon
|
||||
icon="Tag"
|
||||
color="secondary"
|
||||
inverted={props.inverted}
|
||||
class={cx(styles.icon, {
|
||||
[styles.iconSmall]: props.size == "s",
|
||||
})}
|
||||
/>
|
||||
<Combobox.Input
|
||||
onKeyDown={onKeyDown}
|
||||
class={cx(styles.input, {
|
||||
[styles.inputSmall]: props.size == "s",
|
||||
[styles.inputGhost]: props.ghost,
|
||||
[styles.inputInverted]: props.inverted,
|
||||
})}
|
||||
/>
|
||||
</Combobox.Trigger>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
</Orienter>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="machine-tags-content">
|
||||
<Combobox.Listbox class="listbox" />
|
||||
<Combobox.Content
|
||||
class={cx(styles.comboboxContent, {
|
||||
[styles.comboboxContentInverted]: props.inverted,
|
||||
})}
|
||||
>
|
||||
<Combobox.Listbox class={styles.listbox} />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
|
||||
@@ -76,6 +76,19 @@ div.form-field {
|
||||
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
|
||||
}
|
||||
|
||||
& > .start-component {
|
||||
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||
}
|
||||
|
||||
& > .end-component {
|
||||
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
|
||||
}
|
||||
|
||||
& > .start-component,
|
||||
& > .end-component {
|
||||
@apply size-fit;
|
||||
}
|
||||
}
|
||||
|
||||
&.s {
|
||||
@@ -101,7 +114,7 @@ div.form-field {
|
||||
}
|
||||
|
||||
& > .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 cx from "classnames";
|
||||
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Button } from "@kobalte/core/button";
|
||||
|
||||
const Examples = (props: TextInputProps) => (
|
||||
<div class="flex flex-col gap-8">
|
||||
@@ -83,16 +85,38 @@ export const Tooltip: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Icon: Story = {
|
||||
export const WithIcon: Story = {
|
||||
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 = {
|
||||
args: {
|
||||
...Icon.args,
|
||||
...WithIcon.args,
|
||||
ghost: true,
|
||||
},
|
||||
};
|
||||
@@ -106,14 +130,14 @@ export const Invalid: Story = {
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
...Icon.args,
|
||||
...WithIcon.args,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
...Icon.args,
|
||||
...WithIcon.args,
|
||||
readOnly: true,
|
||||
defaultValue: "14/05/02",
|
||||
},
|
||||
|
||||
@@ -11,12 +11,20 @@ import "./TextInput.css";
|
||||
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import { splitProps } from "solid-js";
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onMount,
|
||||
splitProps,
|
||||
} from "solid-js";
|
||||
|
||||
export type TextInputProps = FieldProps &
|
||||
TextFieldRootProps & {
|
||||
icon?: IconVariant;
|
||||
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||
startComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||
endComponent?: Component<Pick<FieldProps, "inverted">>;
|
||||
};
|
||||
|
||||
export const TextInput = (props: TextInputProps) => {
|
||||
@@ -28,6 +36,39 @@ export const TextInput = (props: TextInputProps) => {
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
let startComponentRef: HTMLDivElement | undefined;
|
||||
let endComponentRef: HTMLDivElement | undefined;
|
||||
|
||||
const [startComponentSize, setStartComponentSize] = createSignal({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const [endComponentSize, setEndComponentSize] = createSignal({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (startComponentRef) {
|
||||
const rect = startComponentRef.getBoundingClientRect();
|
||||
setStartComponentSize({ width: rect.width, height: rect.height });
|
||||
}
|
||||
if (endComponentRef) {
|
||||
const rect = endComponentRef.getBoundingClientRect();
|
||||
setEndComponentSize({ width: rect.width, height: rect.height });
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (inputRef) {
|
||||
const padding = props.size == "s" ? 6 : 8;
|
||||
|
||||
inputRef.style.paddingLeft = `${startComponentSize().width + padding * 2}px`;
|
||||
inputRef.style.paddingRight = `${endComponentSize().width + padding * 2}px`;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TextField
|
||||
class={cx(
|
||||
@@ -50,6 +91,11 @@ export const TextInput = (props: TextInputProps) => {
|
||||
{...props}
|
||||
/>
|
||||
<div class="input-container">
|
||||
{props.startComponent && !props.readOnly && (
|
||||
<div ref={startComponentRef} class="start-component">
|
||||
{props.startComponent({ inverted: props.inverted })}
|
||||
</div>
|
||||
)}
|
||||
{props.icon && !props.readOnly && (
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
@@ -58,9 +104,17 @@ export const TextInput = (props: TextInputProps) => {
|
||||
/>
|
||||
)}
|
||||
<TextField.Input
|
||||
ref={inputRef}
|
||||
{...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>
|
||||
</Orienter>
|
||||
</TextField>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.loader {
|
||||
@apply relative;
|
||||
@apply w-4 h-4;
|
||||
@apply size-full;
|
||||
|
||||
&.primary {
|
||||
& > div.wrapper > div.parent,
|
||||
@@ -15,6 +15,18 @@
|
||||
background: #0051ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.sizeDefault {
|
||||
@apply size-4;
|
||||
}
|
||||
|
||||
&.sizeLarge {
|
||||
@apply size-8;
|
||||
}
|
||||
|
||||
&.sizeExtraLarge {
|
||||
@apply size-12;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { Loader, LoaderProps } from "@/src/components/Loader/Loader";
|
||||
|
||||
const LoaderExamples = (props: LoaderProps) => (
|
||||
<div class="grid grid-cols-8">
|
||||
<Loader {...props} size="default" />
|
||||
<Loader {...props} size="l" />
|
||||
<Loader {...props} size="xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meta: Meta<LoaderProps> = {
|
||||
title: "Components/Loader",
|
||||
component: Loader,
|
||||
component: LoaderExamples,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -7,15 +7,23 @@ export type Hierarchy = "primary" | "secondary";
|
||||
export interface LoaderProps {
|
||||
hierarchy?: Hierarchy;
|
||||
class?: string;
|
||||
size?: "default" | "l" | "xl";
|
||||
}
|
||||
|
||||
export const Loader = (props: LoaderProps) => {
|
||||
const size = () => props.size || "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cx(
|
||||
styles.loader,
|
||||
styles[props.hierarchy || "primary"],
|
||||
props.class,
|
||||
{
|
||||
[styles.sizeDefault]: size() === "default",
|
||||
[styles.sizeLarge]: size() === "l",
|
||||
[styles.sizeExtraLarge]: size() === "xl",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div class={styles.wrapper}>
|
||||
|
||||
@@ -20,6 +20,9 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
||||
// we will use css transform in the typography component to capitalize
|
||||
const statusText = () => props.status?.replaceAll("_", " ");
|
||||
|
||||
// our implementation of machine status in the backend needs more time to bake, so for now we only display if a
|
||||
// machine is not installed
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!status()}>
|
||||
@@ -28,9 +31,6 @@ export const MachineStatus = (props: MachineStatusProps) => {
|
||||
<Match when={status()}>
|
||||
<Badge
|
||||
class={cx("machine-status", {
|
||||
online: status() == "online",
|
||||
offline: status() == "offline",
|
||||
"out-of-sync": status() == "out_of_sync",
|
||||
"not-installed": status() == "not_installed",
|
||||
})}
|
||||
textValue={status()}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.sidebar {
|
||||
@apply w-60 border-none z-10 h-full flex flex-col;
|
||||
@apply w-60 border-none z-10 h-full flex flex-col rounded-b-md overflow-hidden;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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;
|
||||
|
||||
/* TODO: This is weird, we shouldn't disable native browser features, a11y impacts incomming */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
|
||||
linear-gradient(
|
||||
@@ -20,13 +21,14 @@ div.sidebar-body {
|
||||
@apply backdrop-blur-sm;
|
||||
|
||||
.accordion {
|
||||
@apply w-full mb-4;
|
||||
@apply w-full mb-4 h-full flex flex-col justify-start gap-4;
|
||||
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
& > .item {
|
||||
max-height: 50%;
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
@@ -58,9 +60,13 @@ div.sidebar-body {
|
||||
}
|
||||
|
||||
& > .content {
|
||||
@apply overflow-hidden flex flex-col;
|
||||
@apply flex flex-col;
|
||||
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
|
||||
|
||||
max-height: calc(100% - 24px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
|
||||
&[data-expanded] {
|
||||
|
||||
@@ -3,13 +3,14 @@ import { A } from "@solidjs/router";
|
||||
import { Accordion } from "@kobalte/core/accordion";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { For, Show, useContext } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
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 { SidebarProps } from "./Sidebar";
|
||||
import { Button } from "../Button/Button";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { Instance } from "@/src/workflows/Service/models";
|
||||
|
||||
interface MachineProps {
|
||||
clanURI: string;
|
||||
@@ -33,19 +34,19 @@ const MachineRoute = (props: MachineProps) => {
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
inverted
|
||||
>
|
||||
{props.name}
|
||||
</Typography>
|
||||
<MachineStatus status={status()} />
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center gap-1">
|
||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
||||
<Icon icon="Flash" size="0.75rem" inverted color="tertiary" />
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="s"
|
||||
inverted={true}
|
||||
inverted
|
||||
color="primary"
|
||||
>
|
||||
{props.serviceCount}
|
||||
@@ -56,18 +57,13 @@ const MachineRoute = (props: MachineProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarBody = (props: SidebarProps) => {
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const Machines = () => {
|
||||
const ctx = useClanContext();
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
}
|
||||
|
||||
const sectionLabels = (props.staticSections || []).map(
|
||||
(section) => section.title,
|
||||
);
|
||||
|
||||
// controls which sections are open by default
|
||||
// we want them all to be open by default
|
||||
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
||||
const clanURI = ctx.clanURI;
|
||||
|
||||
const machines = () => {
|
||||
if (!ctx.machinesQuery.isSuccess) {
|
||||
@@ -78,6 +74,173 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
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 (
|
||||
<div class="sidebar-body">
|
||||
<Accordion
|
||||
@@ -85,66 +248,8 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
multiple
|
||||
defaultValue={defaultAccordionValues}
|
||||
>
|
||||
<Accordion.Item class="item" value="your-machines">
|
||||
<Accordion.Header class="header">
|
||||
<Accordion.Trigger class="trigger">
|
||||
<Typography
|
||||
class="section-title"
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
inverted={true}
|
||||
color="tertiary"
|
||||
>
|
||||
Your Machines
|
||||
</Typography>
|
||||
<Icon
|
||||
icon="CaretDown"
|
||||
color="tertiary"
|
||||
inverted={true}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="content">
|
||||
<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>
|
||||
<Machines />
|
||||
<Services />
|
||||
|
||||
<For each={props.staticSections}>
|
||||
{(section) => (
|
||||
@@ -156,7 +261,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
inverted={true}
|
||||
inverted
|
||||
color="tertiary"
|
||||
>
|
||||
{section.title}
|
||||
@@ -164,7 +269,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
<Icon
|
||||
icon="CaretDown"
|
||||
color="tertiary"
|
||||
inverted={true}
|
||||
inverted
|
||||
size="0.75rem"
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
@@ -179,7 +284,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
inverted
|
||||
>
|
||||
{link.label}
|
||||
</Typography>
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as v from "valibot";
|
||||
import { splitProps } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||
import { setValue } from "@modular-forms/solid";
|
||||
|
||||
type Story = StoryObj<SidebarPaneProps>;
|
||||
|
||||
@@ -137,18 +138,21 @@ export const Default: Story = {
|
||||
console.log("saving tags", values);
|
||||
}}
|
||||
>
|
||||
{({ editing, Field }) => (
|
||||
{({ editing, Field, formStore }) => (
|
||||
<Field name="tags" type="string[]">
|
||||
{(field, input) => (
|
||||
{(field, props) => (
|
||||
<MachineTags
|
||||
{...splitProps(field, ["value"])[1]}
|
||||
size="s"
|
||||
onChange={(newVal) => {
|
||||
// Workaround for now, until we manage to use native events
|
||||
setValue(formStore, field.name, newVal);
|
||||
}}
|
||||
inverted
|
||||
required
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
defaultValue={field.value}
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal, JSX, Show } from "solid-js";
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
FormStore,
|
||||
getErrors,
|
||||
Maybe,
|
||||
PartialValues,
|
||||
@@ -25,6 +26,7 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
|
||||
children: (ctx: {
|
||||
editing: boolean;
|
||||
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
|
||||
formStore: FormStore<FormValues>;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
@@ -51,6 +53,8 @@ export function SidebarSectionForm<
|
||||
};
|
||||
|
||||
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
||||
console.log("Submitting SidebarForm", values);
|
||||
|
||||
await props.onSubmit(values);
|
||||
setEditing(false);
|
||||
};
|
||||
@@ -109,7 +113,7 @@ export function SidebarSectionForm<
|
||||
</Typography>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children({ editing: editing(), Field })}
|
||||
{props.children({ editing: editing(), Field, formStore })}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMachineName } from "@/src/hooks/clan";
|
||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import styles from "./SidebarSectionInstall.module.css";
|
||||
import { Alert } from "../Alert/Alert";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
export interface SidebarSectionInstallProps {
|
||||
clanURI: string;
|
||||
@@ -12,8 +13,8 @@ export interface SidebarSectionInstallProps {
|
||||
}
|
||||
|
||||
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||
const ctx = useClanContext();
|
||||
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||
|
||||
const [showInstall, setShowModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
@@ -32,7 +33,12 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||
<InstallModal
|
||||
open={showInstall()}
|
||||
machineName={useMachineName()}
|
||||
onClose={() => setShowModal(false)}
|
||||
onClose={async () => {
|
||||
// refresh some queries
|
||||
ctx.machinesQuery.refetch();
|
||||
ctx.serviceInstancesQuery.refetch();
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</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 { 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 decodeBase64 = (value: string) => window.atob(value);
|
||||
@@ -30,6 +30,47 @@ export const buildClanPath = (clanURI: string) => {
|
||||
export const buildMachinePath = (clanURI: string, name: string) =>
|
||||
buildClanPath(clanURI) + "/machines/" + name;
|
||||
|
||||
export const buildServicePath = (props: {
|
||||
clanURI: string;
|
||||
id: string;
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null | undefined;
|
||||
};
|
||||
}) => {
|
||||
const { clanURI, id, module } = props;
|
||||
|
||||
const moduleName = encodeBase64(module.name);
|
||||
const idEncoded = encodeBase64(id);
|
||||
|
||||
const result =
|
||||
buildClanPath(clanURI) +
|
||||
`/services/${moduleName}/${idEncoded}` +
|
||||
(module.input ? `?input=${module.input}` : "");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useServiceParams = () => {
|
||||
const params = useParams<{
|
||||
name?: string;
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
const [search] = useSearchParams<{ input?: string }>();
|
||||
|
||||
if (!params.name || !params.id) {
|
||||
console.error("Service params not found", params, window.location.pathname);
|
||||
throw new Error("Service params not found");
|
||||
}
|
||||
|
||||
return {
|
||||
name: decodeBase64(params.name),
|
||||
id: decodeBase64(params.id),
|
||||
input: search.input,
|
||||
};
|
||||
};
|
||||
|
||||
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
||||
const path = buildClanPath(clanURI);
|
||||
console.log("Navigating to clan", clanURI, path);
|
||||
@@ -64,7 +105,21 @@ export const machineNameParam = (params: Params) => {
|
||||
return params.machineName;
|
||||
};
|
||||
|
||||
export const inputParam = (params: Params) => params.input;
|
||||
export const nameParam = (params: Params) => params.name;
|
||||
export const idParam = (params: Params) => params.id;
|
||||
|
||||
export const useMachineName = (): string => machineNameParam(useParams());
|
||||
export const useInputParam = (): string => inputParam(useParams());
|
||||
export const useNameParam = (): string => nameParam(useParams());
|
||||
|
||||
export const maybeUseIdParam = (): string | null => {
|
||||
const params = useParams();
|
||||
if (params.id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return idParam(params);
|
||||
};
|
||||
|
||||
export const maybeUseMachineName = (): string | null => {
|
||||
const params = useParams();
|
||||
|
||||
@@ -25,6 +25,9 @@ export type MachineStatus = MachineState["status"];
|
||||
export type ListMachines = SuccessData<"list_machines">;
|
||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||
|
||||
export type ListServiceModules = SuccessData<"list_service_modules">;
|
||||
export type ListServiceInstances = SuccessData<"list_service_instances">;
|
||||
|
||||
export interface MachineDetail {
|
||||
tags: Tags;
|
||||
machine: Machine;
|
||||
@@ -47,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryKey: [...clanKey(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
const api = client.fetch("list_machines", {
|
||||
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) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetail>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryKey: [machineKey(clanURI, machineName)],
|
||||
queryFn: async () => {
|
||||
const [tagsCall, machineCall, schemaCall] = [
|
||||
client.fetch("list_tags", {
|
||||
@@ -122,7 +131,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
|
||||
export const useTags = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "tags"],
|
||||
queryKey: [...clanKey(clanURI), "tags"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("list_tags", {
|
||||
flake: {
|
||||
@@ -142,8 +151,7 @@ export const useTags = (clanURI: string) => {
|
||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
staleTime: 60_000, // 1 minute stale time
|
||||
queryKey: [...machineKey(clanURI, machineName), "state"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
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 = (
|
||||
clanURI: string,
|
||||
machineName: string,
|
||||
) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
||||
queryKey: [machineKey(clanURI, machineName), "details"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_details", {
|
||||
machine: {
|
||||
@@ -202,7 +258,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
|
||||
export const useClanDetailsQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ClanDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
queryKey: [...clanKey(clanURI), "details"],
|
||||
persister: ClanDetailsPersister.persisterFn,
|
||||
queryFn: async () => {
|
||||
const args = {
|
||||
@@ -253,7 +309,8 @@ export const useClanListQuery = (
|
||||
|
||||
return useQueries(() => ({
|
||||
queries: clanURIs.map((clanURI) => {
|
||||
const queryKey = ["clans", encodeBase64(clanURI), "details"];
|
||||
// @BMG: Is duplicating query key intentional?
|
||||
const queryKey = [...clanKey(clanURI), "details"];
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
@@ -322,7 +379,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
|
||||
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineFlashOptions>(() => ({
|
||||
queryKey: ["clans", "machine_flash_options"],
|
||||
queryKey: ["flash_options"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_flash_options", {});
|
||||
const result = await call.result;
|
||||
@@ -486,7 +543,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
|
||||
export const useServiceModules = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
||||
queryKey: [...clanKey(clanUri), "service_modules"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_modules", {
|
||||
flake: {
|
||||
@@ -506,12 +563,14 @@ export const useServiceModules = (clanUri: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
|
||||
|
||||
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
||||
export type ServiceInstances = SuccessData<"list_service_instances">;
|
||||
export const useServiceInstances = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
||||
queryKey: [...clanKey(clanUri), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
flake: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
@@ -17,13 +17,15 @@ import {
|
||||
useClanURI,
|
||||
useMachineName,
|
||||
} from "@/src/hooks/clan";
|
||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import {
|
||||
ClanDetails,
|
||||
ListServiceInstances,
|
||||
MachinesQueryResult,
|
||||
useClanDetailsQuery,
|
||||
useClanListQuery,
|
||||
useMachinesQuery,
|
||||
useServiceInstancesQuery,
|
||||
} from "@/src/hooks/queries";
|
||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
@@ -33,37 +35,27 @@ import styles from "./Clan.module.css";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
machinesQuery: MachinesQueryResult;
|
||||
activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
isLoading(): boolean;
|
||||
isError(): boolean;
|
||||
|
||||
showAddMachine(): boolean;
|
||||
setShowAddMachine(value: boolean): void;
|
||||
}
|
||||
export type WorldMode = "default" | "select" | "service" | "create" | "move";
|
||||
|
||||
function createClanContext(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
|
||||
) {
|
||||
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
|
||||
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||
const allQueries = [machinesQuery, ...allClansQueries];
|
||||
const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
|
||||
|
||||
return {
|
||||
clanURI,
|
||||
@@ -71,14 +63,23 @@ function createClanContext(
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
allClansQueries,
|
||||
serviceInstancesQuery,
|
||||
isLoading: () => allQueries.some((q) => q.isLoading),
|
||||
isError: () => activeClanQuery.isError,
|
||||
showAddMachine,
|
||||
setShowAddMachine,
|
||||
navigateToRoot: () => {
|
||||
if (location.pathname === buildClanPath(clanURI)) return;
|
||||
navigate(buildClanPath(clanURI), { replace: true });
|
||||
},
|
||||
setWorldMode,
|
||||
worldMode,
|
||||
};
|
||||
}
|
||||
|
||||
const ClanContext = createContext<ClanContextProps>();
|
||||
const ClanContext = createContext<
|
||||
ReturnType<typeof createClanContext> | undefined
|
||||
>();
|
||||
|
||||
export const useClanContext = () => {
|
||||
const ctx = useContext(ClanContext);
|
||||
@@ -104,12 +105,14 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
);
|
||||
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
|
||||
|
||||
const ctx = createClanContext(
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
serviceInstancesQuery,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -132,8 +135,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showService, setShowService] = createSignal(false);
|
||||
|
||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
@@ -194,45 +195,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = useApiClient();
|
||||
const handleSubmitService: SubmitServiceHandler = async (
|
||||
instance,
|
||||
action,
|
||||
) => {
|
||||
console.log(action, "Instance", instance);
|
||||
|
||||
if (action !== "create") {
|
||||
toast.error("Only creating new services is supported");
|
||||
return;
|
||||
}
|
||||
const call = client.fetch("create_service_instance", {
|
||||
flake: {
|
||||
identifier: ctx.clanURI,
|
||||
},
|
||||
module_ref: instance.module,
|
||||
roles: instance.roles,
|
||||
});
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
toast.error("Error creating service instance");
|
||||
console.error("Error creating service instance", result.errors);
|
||||
}
|
||||
toast.success("Created");
|
||||
setShowService(false);
|
||||
setWorldMode("select");
|
||||
};
|
||||
|
||||
createEffect(
|
||||
on(worldMode, (mode) => {
|
||||
if (mode === "service") {
|
||||
setShowService(true);
|
||||
} else {
|
||||
// TODO: request soft close instead of forced close
|
||||
setShowService(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -268,15 +231,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
isLoading={ctx.isLoading()}
|
||||
cubesQuery={ctx.machinesQuery}
|
||||
toolbarPopup={
|
||||
<Show when={showService()}>
|
||||
<ServiceWorkflow
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
setShowService(false);
|
||||
setWorldMode("select");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
<Show when={ctx.worldMode() === "service"}>
|
||||
<Show
|
||||
when={location.pathname.includes("/services/")}
|
||||
fallback={
|
||||
<SelectService
|
||||
onClose={() => {
|
||||
ctx.setWorldMode("select");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -6,10 +6,11 @@ import { SectionGeneral } from "./SectionGeneral";
|
||||
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||
|
||||
import styles from "./Machine.module.css";
|
||||
import { SectionServices } from "@/src/routes/Machine/SectionServices";
|
||||
import { SidebarSectionUpdate } from "@/src/components/Sidebar/SidebarSectionUpdate";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -20,13 +21,16 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
navigateToClan(navigate, clanURI);
|
||||
};
|
||||
|
||||
const sections = () => {
|
||||
const Sections = () => {
|
||||
const machineName = useMachineName();
|
||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||
|
||||
console.log("machineName", machineName);
|
||||
|
||||
// we have to update the whole machine model rather than just the sub fields that were changed
|
||||
// for that reason we pass in this common submit handler to each machine sub section
|
||||
const onSubmit = async (values: Partial<MachineModel>) => {
|
||||
console.log("saving tags", values);
|
||||
const call = callApi("set_machine", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
@@ -57,8 +61,13 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
<SidebarSectionUpdate
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
<SectionServices />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -69,16 +78,19 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
<SidebarPane
|
||||
title={useMachineName()}
|
||||
onClose={onClose}
|
||||
subHeader={
|
||||
<Show when={useMachineName()} keyed>
|
||||
<SidebarMachineStatus
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
// the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and
|
||||
// present the user with the ability to install or update a machines based on `installedAt` in the inventory.json
|
||||
//
|
||||
// subHeader={
|
||||
// <Show when={useMachineName()} keyed>
|
||||
// <SidebarMachineStatus
|
||||
// clanURI={clanURI}
|
||||
// machineName={useMachineName()}
|
||||
// />
|
||||
// </Show>
|
||||
// }
|
||||
>
|
||||
{sections()}
|
||||
{Sections()}
|
||||
</SidebarPane>
|
||||
</div>
|
||||
</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 { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||
import { setValue } from "@modular-forms/solid";
|
||||
|
||||
const schema = v.object({
|
||||
tags: v.pipe(v.optional(v.array(v.string()))),
|
||||
@@ -32,7 +33,7 @@ export const SectionTags = (props: SectionTags) => {
|
||||
|
||||
const options = () => {
|
||||
if (!machineQuery.isSuccess) {
|
||||
return [[], []];
|
||||
return [];
|
||||
}
|
||||
|
||||
// these are static values or values which have been configured in nix and
|
||||
@@ -58,7 +59,7 @@ export const SectionTags = (props: SectionTags) => {
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={initialValues()}
|
||||
>
|
||||
{({ editing, Field }) => (
|
||||
{({ editing, Field, formStore }) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field name="tags" type="string[]">
|
||||
{(field, input) => (
|
||||
@@ -72,7 +73,10 @@ export const SectionTags = (props: SectionTags) => {
|
||||
defaultValue={field.value}
|
||||
defaultOptions={options()[0]}
|
||||
readonlyOptions={options()[1]}
|
||||
input={input}
|
||||
onChange={(newVal) => {
|
||||
// Workaround for now, until we manage to use native events
|
||||
setValue(formStore, field.name, newVal);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 { Clan } from "@/src/routes/Clan/Clan";
|
||||
import { Machine } from "@/src/routes/Machine/Machine";
|
||||
import { Service } from "@/src/routes/Service/Service";
|
||||
|
||||
export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
@@ -30,6 +31,15 @@ export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/machines/:machineName",
|
||||
component: Machine,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/services/:name/:id",
|
||||
component: Service,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as THREE from "three";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
// @ts-expect-error: No types for troika-three-text
|
||||
import { Text } from "troika-three-text";
|
||||
import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
|
||||
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
||||
import { FontLoader } from "three/examples/jsm/Addons";
|
||||
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
|
||||
|
||||
// Constants
|
||||
const BASE_SIZE = 0.9;
|
||||
@@ -23,6 +22,71 @@ const BASE_EMISSIVE = 0x0c0c0c;
|
||||
const BASE_SELECTED_COLOR = 0x69b0e3;
|
||||
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
||||
|
||||
export function createMachineMesh() {
|
||||
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: CUBE_COLOR,
|
||||
emissive: CUBE_EMISSIVE,
|
||||
shininess: 100,
|
||||
transparent: true,
|
||||
});
|
||||
|
||||
const cubeMesh = new THREE.Mesh(geometry, material);
|
||||
cubeMesh.castShadow = true;
|
||||
cubeMesh.receiveShadow = true;
|
||||
cubeMesh.name = "cube";
|
||||
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
||||
|
||||
const { baseMesh, baseMaterial } = createCubeBase(
|
||||
BASE_COLOR,
|
||||
BASE_EMISSIVE,
|
||||
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
||||
);
|
||||
|
||||
return {
|
||||
cubeMesh,
|
||||
baseMesh,
|
||||
baseMaterial,
|
||||
geometry,
|
||||
material,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCubeBase(
|
||||
color: THREE.ColorRepresentation,
|
||||
emissive: THREE.ColorRepresentation,
|
||||
geometry: THREE.BoxGeometry,
|
||||
) {
|
||||
const baseMaterial = new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
emissive,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
});
|
||||
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
|
||||
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
|
||||
baseMesh.receiveShadow = false;
|
||||
return { baseMesh, baseMaterial };
|
||||
}
|
||||
|
||||
// Function to build rounded rect shape
|
||||
export function roundedRectShape(w: number, h: number, r: number) {
|
||||
const shape = new THREE.Shape();
|
||||
const x = -w / 2;
|
||||
const y = -h / 2;
|
||||
|
||||
shape.moveTo(x + r, y);
|
||||
shape.lineTo(x + w - r, y);
|
||||
shape.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
shape.lineTo(x + w, y + h - r);
|
||||
shape.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
shape.lineTo(x + r, y + h);
|
||||
shape.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
shape.lineTo(x, y + r);
|
||||
shape.quadraticCurveTo(x, y, x + r, y);
|
||||
return shape;
|
||||
}
|
||||
|
||||
export class MachineRepr {
|
||||
public id: string;
|
||||
public group: THREE.Group;
|
||||
@@ -46,31 +110,21 @@ export class MachineRepr {
|
||||
) {
|
||||
this.id = id;
|
||||
this.camera = camera;
|
||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||
this.material = new THREE.MeshPhongMaterial({
|
||||
color: CUBE_COLOR,
|
||||
emissive: CUBE_EMISSIVE,
|
||||
shininess: 100,
|
||||
});
|
||||
|
||||
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
|
||||
this.cubeMesh.castShadow = true;
|
||||
this.cubeMesh.receiveShadow = true;
|
||||
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
|
||||
this.cubeMesh = cubeMesh;
|
||||
this.cubeMesh.userData = { id };
|
||||
this.cubeMesh.name = "cube";
|
||||
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
||||
|
||||
this.baseMesh = this.createCubeBase(
|
||||
BASE_COLOR,
|
||||
BASE_EMISSIVE,
|
||||
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
||||
);
|
||||
this.baseMesh = baseMesh;
|
||||
this.baseMesh.name = "base";
|
||||
|
||||
this.geometry = geometry;
|
||||
this.material = material;
|
||||
|
||||
const label = this.createLabel(id);
|
||||
|
||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||
color: BASE_COLOR, // any color you like
|
||||
color: BASE_COLOR,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
@@ -104,8 +158,6 @@ export class MachineRepr {
|
||||
const highlightedGroups = groups
|
||||
.filter(([, ids]) => ids.has(this.id))
|
||||
.map(([name]) => name);
|
||||
|
||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
@@ -122,9 +174,6 @@ export class MachineRepr {
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||
);
|
||||
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||
// );
|
||||
|
||||
renderLoop.requestRender();
|
||||
},
|
||||
@@ -149,45 +198,85 @@ export class MachineRepr {
|
||||
renderLoop.requestRender();
|
||||
}
|
||||
|
||||
private createCubeBase(
|
||||
color: THREE.ColorRepresentation,
|
||||
emissive: THREE.ColorRepresentation,
|
||||
geometry: THREE.BoxGeometry,
|
||||
) {
|
||||
const baseMaterial = new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
emissive,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
});
|
||||
const base = new THREE.Mesh(geometry, baseMaterial);
|
||||
base.position.set(0, BASE_HEIGHT / 2, 0);
|
||||
base.receiveShadow = false;
|
||||
return base;
|
||||
}
|
||||
|
||||
private createLabel(id: string) {
|
||||
const text = new Text();
|
||||
text.text = id;
|
||||
text.font = ttf;
|
||||
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
|
||||
text.fontSize = 0.15; // relative to your cube size
|
||||
text.color = 0x000000; // any THREE.Color
|
||||
text.anchorX = "center"; // horizontal centering
|
||||
text.anchorY = "bottom"; // baseline aligns to cube top
|
||||
text.position.set(0, CUBE_SIZE + 0.05, 0);
|
||||
const group = new THREE.Group();
|
||||
// 0x162324
|
||||
// const text = new Text();
|
||||
// text.text = id;
|
||||
// text.font = ttf;
|
||||
// text.fontSize = 0.1;
|
||||
// text.color = 0xffffff;
|
||||
// text.anchorX = "center";
|
||||
// text.anchorY = "middle";
|
||||
// text.position.set(0, 0, 0.01);
|
||||
// text.outlineWidth = 0.005;
|
||||
// text.outlineColor = 0x162324;
|
||||
// text.sync(() => {
|
||||
// renderLoop.requestRender();
|
||||
// });
|
||||
|
||||
// If you want it to always face camera:
|
||||
text.userData.isLabel = true;
|
||||
text.outlineWidth = 0.005;
|
||||
text.outlineColor = 0x333333;
|
||||
text.quaternion.copy(this.camera.quaternion);
|
||||
|
||||
// Re-render on text changes
|
||||
text.sync(() => {
|
||||
renderLoop.requestRender();
|
||||
const textMaterial = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
});
|
||||
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) {
|
||||
@@ -197,12 +286,13 @@ export class MachineRepr {
|
||||
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
|
||||
this.group.clear();
|
||||
|
||||
for (const child of this.cubeMesh.children) {
|
||||
if (child instanceof THREE.Mesh)
|
||||
(child.material as THREE.Material).dispose();
|
||||
|
||||
if (child instanceof CSS2DObject) child.element.remove();
|
||||
|
||||
if (child instanceof THREE.Object3D) child.remove();
|
||||
}
|
||||
(this.baseMesh.material as THREE.Material).dispose();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||
<Show when={show()}> */
|
||||
.toolbar-container {
|
||||
@apply absolute bottom-10 z-10 w-full;
|
||||
@apply absolute bottom-10 z-30 left-1/2;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ import { MachineManager } from "./MachineManager";
|
||||
import cx from "classnames";
|
||||
import { Portal } from "solid-js/web";
|
||||
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(
|
||||
event: MouseEvent,
|
||||
@@ -33,7 +39,7 @@ function intersectMachines(
|
||||
camera: THREE.Camera,
|
||||
machineManager: MachineManager,
|
||||
raycaster: THREE.Raycaster,
|
||||
): string[] {
|
||||
) {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
@@ -44,7 +50,10 @@ function intersectMachines(
|
||||
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) {
|
||||
@@ -86,12 +95,6 @@ export function useMachineClick() {
|
||||
return lastClickedMachine;
|
||||
}
|
||||
|
||||
/*Gloabl signal*/
|
||||
const [worldMode, setWorldMode] = createSignal<
|
||||
"default" | "select" | "service" | "create" | "move"
|
||||
>("select");
|
||||
export { worldMode, setWorldMode };
|
||||
|
||||
export function CubeScene(props: {
|
||||
cubesQuery: MachinesQueryResult;
|
||||
onCreate: () => Promise<{ id: string }>;
|
||||
@@ -103,6 +106,8 @@ export function CubeScene(props: {
|
||||
clanURI: string;
|
||||
toolbarPopup?: JSX.Element;
|
||||
}) {
|
||||
const ctx = useClanContext();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.OrthographicCamera;
|
||||
@@ -113,6 +118,7 @@ export function CubeScene(props: {
|
||||
// Raycaster for clicking
|
||||
const raycaster = new THREE.Raycaster();
|
||||
let actionBase: THREE.Mesh | undefined;
|
||||
let actionMachine: THREE.Group | undefined;
|
||||
|
||||
// Create background scene
|
||||
const bgScene = new THREE.Scene();
|
||||
@@ -123,12 +129,17 @@ export function CubeScene(props: {
|
||||
let sharedCubeGeometry: THREE.BoxGeometry;
|
||||
let sharedBaseGeometry: THREE.BoxGeometry;
|
||||
|
||||
let machineManager: MachineManager;
|
||||
|
||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||
"grid",
|
||||
);
|
||||
// Managed by controls
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
|
||||
const [cancelMove, setCancelMove] = createSignal<NodeJS.Timeout>();
|
||||
|
||||
// TODO: Unify this with actionRepr position
|
||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||
|
||||
const [cameraInfo, setCameraInfo] = createSignal({
|
||||
@@ -300,12 +311,12 @@ export function CubeScene(props: {
|
||||
bgCamera,
|
||||
);
|
||||
|
||||
controls.addEventListener("start", (e) => {
|
||||
setIsDragging(true);
|
||||
});
|
||||
controls.addEventListener("end", (e) => {
|
||||
setIsDragging(false);
|
||||
});
|
||||
// controls.addEventListener("start", (e) => {
|
||||
// setIsDragging(true);
|
||||
// });
|
||||
// controls.addEventListener("end", (e) => {
|
||||
// setIsDragging(false);
|
||||
// });
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||
@@ -384,6 +395,23 @@ export function CubeScene(props: {
|
||||
|
||||
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();
|
||||
// spherical.setFromVector3(camera.position);
|
||||
|
||||
@@ -409,7 +437,7 @@ export function CubeScene(props: {
|
||||
updateCameraInfo();
|
||||
|
||||
createEffect(
|
||||
on(worldMode, (mode) => {
|
||||
on(ctx.worldMode, (mode) => {
|
||||
if (mode === "create") {
|
||||
actionBase!.visible = true;
|
||||
} else {
|
||||
@@ -421,7 +449,7 @@ export function CubeScene(props: {
|
||||
|
||||
const registry = new ObjectRegistry();
|
||||
|
||||
const machineManager = new MachineManager(
|
||||
machineManager = new MachineManager(
|
||||
scene,
|
||||
registry,
|
||||
props.sceneStore,
|
||||
@@ -435,7 +463,7 @@ export function CubeScene(props: {
|
||||
// - Select/deselects a cube in mode
|
||||
// - Creates a new cube in "create" mode
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (worldMode() === "create") {
|
||||
if (ctx.worldMode() === "create") {
|
||||
props
|
||||
.onCreate()
|
||||
.then(({ id }) => {
|
||||
@@ -453,17 +481,16 @@ export function CubeScene(props: {
|
||||
.finally(() => {
|
||||
if (actionBase) actionBase.visible = false;
|
||||
|
||||
setWorldMode("default");
|
||||
ctx.setWorldMode("select");
|
||||
});
|
||||
}
|
||||
if (worldMode() === "move") {
|
||||
console.log("sanpped");
|
||||
if (ctx.worldMode() === "move") {
|
||||
const currId = menuIntersection().at(0);
|
||||
const pos = cursorPosition();
|
||||
if (!currId || !pos) return;
|
||||
|
||||
props.setMachinePos(currId, pos);
|
||||
setWorldMode("select");
|
||||
ctx.setWorldMode("select");
|
||||
clearHighlight("move");
|
||||
}
|
||||
|
||||
@@ -477,18 +504,20 @@ export function CubeScene(props: {
|
||||
const intersects = raycaster.intersectObjects(
|
||||
Array.from(machineManager.machines.values().map((m) => m.group)),
|
||||
);
|
||||
console.log("Intersects:", intersects);
|
||||
if (intersects.length > 0) {
|
||||
console.log("Clicked on cube:", intersects);
|
||||
const id = intersects[0].object.userData.id;
|
||||
const id = intersects.find((i) => i.object.userData?.id)?.object
|
||||
.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
|
||||
} else {
|
||||
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 { machines, intersection } = intersectMachines(
|
||||
e,
|
||||
renderer,
|
||||
camera,
|
||||
machineManager,
|
||||
raycaster,
|
||||
);
|
||||
if (e.button === 0) {
|
||||
// Left button
|
||||
|
||||
if (ctx.worldMode() === "select" && machines.length) {
|
||||
// Disable controls to avoid conflict
|
||||
controls.enabled = false;
|
||||
|
||||
// Change cursor to grabbing
|
||||
// LongPress, if not canceled, enters move mode
|
||||
const cancelMove = setTimeout(() => {
|
||||
setIsDragging(true);
|
||||
const pos =
|
||||
machineManager.machines.get(machines[0])?.group.position ||
|
||||
new THREE.Vector3(0, 0, 0);
|
||||
actionMachine?.position.set(pos.x, 0, pos.z);
|
||||
// Set machine as flying
|
||||
setHighlightGroups({ move: new Set(machines) });
|
||||
|
||||
ctx.setWorldMode("move");
|
||||
renderLoop.requestRender();
|
||||
}, 500);
|
||||
setCancelMove(cancelMove);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.button === 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const intersection = intersectMachines(
|
||||
e,
|
||||
renderer,
|
||||
camera,
|
||||
machineManager,
|
||||
raycaster,
|
||||
);
|
||||
if (!intersection.length) return;
|
||||
setMenuIntersection(intersection);
|
||||
setMenuIntersection(machines);
|
||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setContextOpen(true);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
setIsDragging(false);
|
||||
if (cancelMove()) {
|
||||
clearTimeout(cancelMove()!);
|
||||
setCancelMove(undefined);
|
||||
}
|
||||
// Always re-enable controls
|
||||
controls.enabled = true;
|
||||
|
||||
if (ctx.worldMode() === "move") {
|
||||
// Set machine as not flying
|
||||
const pos = actionMachine!.position.toArray();
|
||||
props.setMachinePos(highlightGroups["move"].values().next().value!, [
|
||||
pos[0], // x
|
||||
pos[2], // z
|
||||
]);
|
||||
clearHighlight("move");
|
||||
ctx.setWorldMode("select");
|
||||
renderLoop.requestRender();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
||||
renderer.domElement.addEventListener("mouseup", handleMouseUp);
|
||||
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
@@ -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) => {
|
||||
setPositionMode("grid");
|
||||
setWorldMode("create");
|
||||
ctx.setWorldMode("create");
|
||||
renderLoop.requestRender();
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
||||
if (!actionBase) return;
|
||||
if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return;
|
||||
|
||||
console.log("Mouse move in create/move mode");
|
||||
const actionRepr =
|
||||
ctx.worldMode() === "create" ? actionBase : actionMachine;
|
||||
if (!actionRepr) return;
|
||||
|
||||
actionBase.visible = true;
|
||||
(actionBase.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
||||
);
|
||||
actionRepr.visible = true;
|
||||
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
||||
// );
|
||||
|
||||
// Calculate mouse position in normalized device coordinates
|
||||
// (-1 to +1) for both components
|
||||
@@ -611,41 +723,45 @@ export function CubeScene(props: {
|
||||
if (intersects.length > 0) {
|
||||
const point = intersects[0].point;
|
||||
|
||||
// Snap to grid
|
||||
const snapped = new THREE.Vector3(
|
||||
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
||||
0,
|
||||
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
|
||||
);
|
||||
|
||||
// Skip snapping if there's already a cube at this position
|
||||
if (props.sceneStore()) {
|
||||
const positions = Object.values(props.sceneStore());
|
||||
const intersects = positions.some(
|
||||
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
|
||||
);
|
||||
if (intersects) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const snapped = snapToGrid(point);
|
||||
if (!snapped) return;
|
||||
if (
|
||||
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(actionBase.position.z - snapped.z) > 0.01
|
||||
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(actionRepr.position.z - snapped.z) > 0.01
|
||||
) {
|
||||
// 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
|
||||
renderLoop.requestRender();
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleMenuSelect = (mode: "move") => {
|
||||
setWorldMode(mode);
|
||||
ctx.setWorldMode(mode);
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -664,10 +780,10 @@ export function CubeScene(props: {
|
||||
<div
|
||||
class={cx(
|
||||
"cubes-scene-container",
|
||||
worldMode() === "default" && "cursor-no-drop",
|
||||
worldMode() === "select" && "cursor-pointer",
|
||||
worldMode() === "service" && "cursor-pointer",
|
||||
worldMode() === "create" && "cursor-cell",
|
||||
ctx.worldMode() === "default" && "cursor-no-drop",
|
||||
ctx.worldMode() === "select" && "cursor-pointer",
|
||||
ctx.worldMode() === "service" && "cursor-pointer",
|
||||
ctx.worldMode() === "create" && "cursor-cell",
|
||||
isDragging() && "!cursor-grabbing",
|
||||
)}
|
||||
ref={(el) => (container = el)}
|
||||
@@ -681,24 +797,25 @@ export function CubeScene(props: {
|
||||
description="Select machine"
|
||||
name="Select"
|
||||
icon="Cursor"
|
||||
onClick={() => setWorldMode("select")}
|
||||
selected={worldMode() === "select"}
|
||||
onClick={() => ctx.setWorldMode("select")}
|
||||
selected={ctx.worldMode() === "select"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
description="Create new machine"
|
||||
name="new-machine"
|
||||
icon="NewMachine"
|
||||
onClick={onAddClick}
|
||||
selected={worldMode() === "create"}
|
||||
selected={ctx.worldMode() === "create"}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<ToolbarButton
|
||||
description="Add new Service"
|
||||
name="modules"
|
||||
icon="Services"
|
||||
selected={worldMode() === "service"}
|
||||
selected={ctx.worldMode() === "service"}
|
||||
onClick={() => {
|
||||
setWorldMode("service");
|
||||
ctx.navigateToRoot();
|
||||
ctx.setWorldMode("service");
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
|
||||
@@ -6,3 +6,35 @@ export const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>
|
||||
},
|
||||
{} as Pick<T, K>,
|
||||
);
|
||||
|
||||
export const removeEmptyStrings = <T>(obj: T): T => {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj === "string") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeEmptyStrings(item)) as T;
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = {};
|
||||
for (const key in obj) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const value = obj[key];
|
||||
if (value !== "") {
|
||||
result[key] = removeEmptyStrings(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
@@ -26,13 +26,19 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
const resultData: Partial<ResultDataMap> = {
|
||||
list_machines: {
|
||||
pandora: {
|
||||
name: "pandora",
|
||||
data: {
|
||||
name: "pandora",
|
||||
},
|
||||
},
|
||||
enceladus: {
|
||||
name: "enceladus",
|
||||
data: {
|
||||
name: "enceladus",
|
||||
},
|
||||
},
|
||||
dione: {
|
||||
name: "dione",
|
||||
data: {
|
||||
name: "dione",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface AddMachineProps {
|
||||
export interface AddMachineStoreType {
|
||||
general: GeneralForm;
|
||||
deploy: {
|
||||
targetHost: string;
|
||||
targetHost?: string;
|
||||
};
|
||||
tags: {
|
||||
tags: string[];
|
||||
@@ -111,10 +111,7 @@ export const AddMachine = (props: AddMachineProps) => {
|
||||
return defaultClass;
|
||||
}
|
||||
|
||||
switch (currentStep.id) {
|
||||
default:
|
||||
return defaultClass;
|
||||
}
|
||||
return defaultClass;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,7 @@ export const StepProgress = (props: StepProgressProps) => {
|
||||
when={store.error}
|
||||
fallback={
|
||||
<>
|
||||
<Loader class="size-8" />
|
||||
<Loader size="l" />
|
||||
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||
{store.general?.name} is being created
|
||||
</Typography>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user