Compare commits

..

1 Commits

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

View File

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

View File

@@ -50,7 +50,6 @@
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.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; };

View File

@@ -1,10 +0,0 @@
system:
builtins.fetchurl {
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${system}.json";
sha256 =
{
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
}
.${system};
}

View File

@@ -18,23 +18,27 @@
fileSystems."/".device = lib.mkDefault "/dev/vda";
boot.loader.grub.device = lib.mkDefault "/dev/vda";
imports = [
self.nixosModules.test-install-machine-without-system
];
imports = [ self.nixosModules.test-install-machine-without-system ];
};
clan.machines.test-install-machine-with-system =
{ pkgs, ... }:
{
# https://git.clan.lol/clan/test-fixtures
facter.reportPath = import ./facter-report.nix pkgs.hostPlatform.system;
facter.reportPath = builtins.fetchurl {
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${pkgs.hostPlatform.system}.json";
sha256 =
{
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
}
.${pkgs.hostPlatform.system};
};
fileSystems."/".device = lib.mkDefault "/dev/vda";
boot.loader.grub.device = lib.mkDefault "/dev/vda";
imports = [ self.nixosModules.test-install-machine-without-system ];
};
flake.nixosModules = {
test-install-machine-without-system =
{ lib, modulesPath, ... }:
@@ -155,7 +159,6 @@
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
(import ./facter-report.nix pkgs.hostPlatform.system)
]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
};

View File

@@ -35,7 +35,6 @@
pkgs.stdenv.drvPath
pkgs.stdenvNoCC
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };

View File

@@ -112,7 +112,6 @@
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
};

View File

@@ -5,7 +5,7 @@ inventory.instances = {
borgbackup = {
module = {
name = "borgbackup";
input = "clan-core";
input = "clan";
};
roles.client.machines."jon".settings = {
destinations."storagebox" = {

View File

@@ -1,5 +1,4 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "coredns";
@@ -26,12 +25,6 @@
# TODO: Set a default
description = "IP for the DNS to listen on";
};
options.dnsPort = lib.mkOption {
type = lib.types.int;
default = 1053;
description = "Port of the clan-internal DNS server";
};
};
perInstance =
@@ -49,8 +42,8 @@
}:
{
networking.firewall.allowedTCPPorts = [ settings.dnsPort ];
networking.firewall.allowedUDPPorts = [ settings.dnsPort ];
networking.firewall.allowedTCPPorts = [ 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
services.coredns =
let
@@ -81,22 +74,16 @@
in
{
enable = true;
config =
config = ''
. {
forward . 1.1.1.1
cache 30
}
let
dnsPort = builtins.toString settings.dnsPort;
in
''
.:${dnsPort} {
forward . 1.1.1.1
cache 30
}
${settings.tld}:${dnsPort} {
file ${zonefile}
}
'';
${settings.tld} {
file ${zonefile}
}
'';
};
};
};
@@ -120,16 +107,10 @@
# TODO: Set a default
description = "IP on which the services will listen";
};
options.dnsPort = lib.mkOption {
type = lib.types.int;
default = 1053;
description = "Port of the clan-internal DNS server";
};
};
perInstance =
{ roles, settings, ... }:
{ roles, ... }:
{
nixosModule =
{ lib, ... }:
@@ -166,7 +147,7 @@
];
stub-zone = map (m: {
name = "${roles.server.machines.${m}.settings.tld}.";
stub-addr = "${roles.server.machines.${m}.settings.ip}@${builtins.toString settings.dnsPort}";
stub-addr = "${roles.server.machines.${m}.settings.ip}";
}) (lib.attrNames roles.server.machines);
};
};

View File

@@ -95,15 +95,18 @@
for m in machines:
m.wait_for_unit("network-online.target")
# import time
# time.sleep(2333333)
# This should work, but is borken in tests i think? Instead we dig directly
# client.succeed("curl -k -v http://one.foo")
# client.succeed("curl -k -v http://two.foo")
answer = client.succeed("dig @192.168.1.2 -p 1053 one.foo")
answer = client.succeed("dig @192.168.1.2 one.foo")
assert "192.168.1.3" in answer, "IP not found"
answer = client.succeed("dig @192.168.1.2 -p 1053 two.foo")
answer = client.succeed("dig @192.168.1.2 two.foo")
assert "192.168.1.4" in answer, "IP not found"
'';

View File

@@ -56,11 +56,6 @@
systemd.services.telegraf-json = {
enable = true;
wantedBy = [ "multi-user.target" ];
after = [ "telegraf.service" ];
wants = [ "telegraf.service" ];
serviceConfig = {
Restart = "on-failure";
};
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
};

View File

@@ -7,7 +7,7 @@ inventory.instances = {
clan-cache = {
module = {
name = "trusted-nix-caches";
input = "clan-core";
input = "clan";
};
roles.default.machines.draper = { };
};

View File

@@ -8,7 +8,7 @@
user-alice = {
module = {
name = "users";
input = "clan-core";
input = "clan";
};
roles.default.tags.all = { };
roles.default.settings = {
@@ -35,7 +35,7 @@
user-bob = {
module = {
name = "users";
input = "clan-core";
input = "clan";
};
roles.default.machines.bobs-laptop = { };
roles.default.settings.user = "bob";

View File

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

View File

@@ -0,0 +1,116 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/yggdrasil";
manifest.description = "Yggdrasil encrypted IPv6 routing overlay network";
roles.default = {
interface =
{ lib, ... }:
{
options.extraMulticastInterfaces = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
default = [ ];
description = ''
Additional interfaces to use for Multicast. See
https://yggdrasil-network.github.io/configurationref.html#multicastinterfaces
for reference.
'';
example = [
{
Regex = "(wg).*";
Beacon = true;
Listen = true;
Port = 5400;
Priority = 1020;
}
];
};
options.peers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Static peers to configure for this host.
If not set, local peers will be auto-discovered
'';
example = [
"tcp://192.168.1.1:6443"
"quic://192.168.1.1:6443"
"tls://192.168.1.1:6443"
"ws://192.168.1.1:6443"
];
};
};
perInstance =
{ settings, ... }:
{
nixosModule =
{
config,
pkgs,
...
}:
{
clan.core.vars.generators.yggdrasil = {
files.privateKey = { };
files.publicKey.secret = false;
files.address.secret = false;
runtimeInputs = with pkgs; [
yggdrasil
jq
openssl
];
script = ''
# Generate private key
openssl genpkey -algorithm Ed25519 -out $out/privateKey
# Generate corresponding public key
openssl pkey -in $out/privateKey -pubout -out $out/publicKey
# Derive IPv6 address from key
echo "{ \"PrivateKeyPath\": \"$out/privateKey\"}" | yggdrasil -useconf -address > $out/address
'';
};
systemd.services.yggdrasil.serviceConfig.BindReadOnlyPaths = [
"${config.clan.core.vars.generators.yggdrasil.files.privateKey.path}:/var/lib/yggdrasil/key"
];
services.yggdrasil = {
enable = true;
openMulticastPort = true;
persistentKeys = true;
settings = {
PrivateKeyPath = "/var/lib/yggdrasil/key";
IfName = "ygg";
Peers = settings.peers;
MulticastInterfaces = [
# Ethernet is preferred over WIFI
{
Regex = "(eth|en).*";
Beacon = true;
Listen = true;
Port = 5400;
Priority = 1024;
}
{
Regex = "(wl).*";
Beacon = true;
Listen = true;
Port = 5400;
Priority = 1025;
}
]
++ settings.extraMulticastInterfaces;
};
};
networking.firewall.allowedTCPPorts = [ 5400 ];
};
};
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ inventory.instances = {
zerotier = {
module = {
name = "zerotier";
input = "clan-core";
input = "clan";
};
roles.peer.tags.all = { };
roles.controller.machines.jon = { };
@@ -18,6 +18,7 @@ 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
View File

@@ -84,11 +84,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1757449886,
"narHash": "sha256-XNhjHidr4i581CVyufJtrleYYgn/55cQONYG3uvIYEY=",
"lastModified": 1756662818,
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1779f9e0d8b45d88d7525665dd4d2a5b65041248",
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09",
"type": "github"
},
"original": {
@@ -107,15 +107,15 @@
]
},
"locked": {
"lastModified": 1757505024,
"narHash": "sha256-AI4TKqIcUobFVqWD0N+6CWlJC5VMK7+HFtKKKyojG74=",
"owner": "Qubasa",
"lastModified": 1755555503,
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
"owner": "NuschtOS",
"repo": "search",
"rev": "ad978b39eba41d1cb5cf525fa146f91e6db2a895",
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
"type": "github"
},
"original": {
"owner": "Qubasa",
"owner": "NuschtOS",
"repo": "search",
"type": "github"
}

View File

@@ -7,8 +7,7 @@
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.flake-utils.inputs.systems.follows = "systems";
# Using my fork till this is merged: https://github.com/NuschtOS/search/pull/255
inputs.nuschtos.url = "github:Qubasa/search";
inputs.nuschtos.url = "github:NuschtOS/search";
inputs.nuschtos.inputs.nixpkgs.follows = "nixpkgs-dev";
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";

View File

@@ -63,12 +63,6 @@ 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
@@ -119,6 +113,7 @@ nav:
- reference/clanServices/users.md
- reference/clanServices/wifi.md
- reference/clanServices/wireguard.md
- reference/clanServices/yggdrasil.md
- reference/clanServices/zerotier.md
- API: reference/clanServices/clan-service-author-interface.md

View File

@@ -1,45 +1,148 @@
# Generators
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.
Defining a linux user's password via the nixos configuration previously required running `mkpasswd ...` and then copying the hash back into the nix configuration.
## What are Generators?
In this example, we will guide you through automating that interaction using clan `vars`.
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.
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)
A generator defines:
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))
- **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
This section will walk you through the following steps:
## Key Benefits
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
- **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
## Declare a generator
## Common Use Cases
In this example, a `vars` `generator` is used to:
- **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
- 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
## Learning Path
Create a new nix file `root-password.nix` with the following content and import it into your `configuration.nix`
```nix
{config, pkgs, ...}: {
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
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 ];
};
## API Reference
# 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;
}
```
For complete configuration options and technical details, see:
## Inspect the status
- [Vars NixOS Module Reference](../reference/clan.core/vars.md) - All configuration options
- [Vars CLI Reference](../reference/cli/vars.md) - Command-line interface
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)

View File

@@ -21,7 +21,7 @@ The following tutorial will walk through setting up a Backup service where the t
## Services
The inventory defines `instances` of clan services. Membership of `machines` is defined via `roles` exclusively.
The inventory defines `services`. Membership of `machines` is defined via `roles` exclusively.
See each [modules documentation](../reference/clanServices/index.md) for its available roles.
@@ -31,8 +31,9 @@ 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 role configuration via `inventory.instances.<instanceName>.roles.<roleName>.settings`
- Per machine configuration via `inventory.instances.<instanceName>.roles.<roleName>.machines.<machineName>.settings`
- 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`
### Setting up the Backup Service
@@ -43,17 +44,16 @@ Each service can still be customized and configured according to the modules opt
See also: [Multiple Service Instances](#multiple-service-instances)
```{.nix hl_lines="9-10"}
{
inventory.instances.instance_1 = {
module = {
name = "borgbackup";
input = "clan-core";
```{.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" ];
};
};
# 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 18"}
{
```{.nix hl_lines="5 8 14"}
clan-core.lib.clan {
inventory = {
machines = {
"jon" = {
@@ -76,16 +76,13 @@ It is possible to add services to multiple machines via tags as shown
"sara" = {
tags = [ "backup" ];
};
# ...
};
instances.instance_1 = {
module = {
name = "borgbackup";
input = "clan-core";
services = {
borgbackup.instance_1 = {
roles.client.tags = [ "backup" ];
roles.server.machines = [ "backup_server" ];
};
roles.client.tags = [ "backup" ];
roles.server.machines."backup_server" = {};
};
};
}
@@ -101,34 +98,22 @@ 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="17 26"}
{
```{.nix hl_lines="11 14"}
clan-core.lib.clan {
inventory = {
machines = {
"jon" = {};
"backup_server" = {};
"backup_backup_server" = {};
"backup_backup_server" = {}
};
instances = {
instance_1 = {
module = {
name = "borgbackup";
input = "clan-core";
};
roles.client.machines."jon" = {};
roles.server.machines."backup_server" = {};
services = {
borgbackup.instance_1 = {
roles.client.machines = [ "jon" ];
roles.server.machines = [ "backup_server" ];
};
instance_2 = {
module = {
name = "borgbackup";
input = "clan-core";
};
roles.client.machines."backup_server" = {};
roles.server.machines."backup_backup_server" = {};
borgbackup.instance_2 = {
roles.client.machines = [ "backup_server" ];
roles.server.machines = [ "backup_backup_server" ];
};
};
};

View File

@@ -1,3 +1,4 @@
This guide explains how to set up and manage
[BorgBackup](https://borgbackup.readthedocs.io/) for secure, efficient backups
in a clan network. BorgBackup provides:
@@ -17,7 +18,7 @@ inventory.instances = {
borgbackup = {
module = {
name = "borgbackup";
input = "clan-core";
input = "clan";
};
roles.client.machines."jon".settings = {
destinations."storagebox" = {
@@ -176,7 +177,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
@@ -193,3 +194,6 @@ 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
```

View File

@@ -4,8 +4,6 @@ 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:

View File

@@ -19,10 +19,10 @@ For machines with public IPs or DNS names, use the `internet` service to configu
# Direct SSH with fallback support
internet = {
roles.default.machines.server1 = {
settings.host = "server1.example.com";
settings.address = "server1.example.com";
};
roles.default.machines.server2 = {
settings.host = "192.168.1.100";
settings.address = "192.168.1.100";
};
};
@@ -50,7 +50,7 @@ For machines with public IPs or DNS names, use the `internet` service to configu
# Priority 1: Try direct connection first
internet = {
roles.default.machines.publicserver = {
settings.host = "public.example.com";
settings.address = "public.example.com";
};
};

View File

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

View File

@@ -1,155 +0,0 @@
!!! 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)

View File

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

View File

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

View File

@@ -1,266 +0,0 @@
# 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
View File

@@ -13,11 +13,11 @@
]
},
"locked": {
"lastModified": 1757300813,
"narHash": "sha256-JYQl+8nJYImg/inqotu9nEPcTXrRJixFN6sOfn6Tics=",
"rev": "b5f2157bcd26c73551374cd6e5b027b0119b2f3d",
"lastModified": 1756695982,
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=",
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/b5f2157bcd26c73551374cd6e5b027b0119b2f3d.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/cc8f26e7e6c2dc985526ba59b286ae5a83168cdb.tar.gz"
},
"original": {
"type": "tarball",
@@ -31,11 +31,11 @@
]
},
"locked": {
"lastModified": 1757255839,
"narHash": "sha256-XH33B1X888Xc/xEXhF1RPq/kzKElM0D5C9N6YdvOvIc=",
"lastModified": 1756115622,
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
"owner": "nix-community",
"repo": "disko",
"rev": "c8a0e78d86b12ea67be6ed0f7cae7f9bfabae75a",
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
"type": "github"
},
"original": {
@@ -51,11 +51,11 @@
]
},
"locked": {
"lastModified": 1756770412,
"narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=",
"lastModified": 1754487366,
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "4524271976b625a4a605beefd893f270620fd751",
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
"type": "github"
},
"original": {
@@ -71,11 +71,11 @@
]
},
"locked": {
"lastModified": 1757430124,
"narHash": "sha256-MhDltfXesGH8VkGv3hmJ1QEKl1ChTIj9wmGAFfWj/Wk=",
"lastModified": 1755825449,
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "830b3f0b50045cf0bcfd4dab65fad05bf882e196",
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
"type": "github"
},
"original": {

View File

@@ -189,12 +189,8 @@ 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-network-id.restartUnits = [ "zerotierone.service" ];
files.zerotier-identity-secret = {
restartUnits = [ "zerotierone.service" ];
};
files.zerotier-identity-secret = { };
runtimeInputs = [
config.services.zerotierone.package
pkgs.python3
@@ -215,10 +211,7 @@ in
clan.core.vars.generators.zerotier = {
migrateFact = "zerotier";
files.zerotier-ip.secret = false;
files.zerotier-ip.restartUnits = [ "zerotierone.service" ];
files.zerotier-identity-secret = {
restartUnits = [ "zerotierone.service" ];
};
files.zerotier-identity-secret = { };
runtimeInputs = [
config.services.zerotierone.package
pkgs.python3

View File

@@ -109,7 +109,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
title="Clan App",
size=Size(1280, 1024, SizeHint.NONE),
shared_threads=shared_threads,
app_id="org.clan.app",
)
API.overwrite_fn(get_system_file)

View File

@@ -5,11 +5,6 @@ import platform
from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p
from pathlib import Path
# Native handle kinds
WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW = 0
WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET = 1
WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER = 2
def _encode_c_string(s: str) -> bytes:
return s.encode("utf-8")
@@ -77,10 +72,6 @@ class _WebviewLibrary:
self.webview_create.argtypes = [c_int, c_void_p]
self.webview_create.restype = c_void_p
self.webview_create_with_app_id = self.lib.webview_create_with_app_id
self.webview_create_with_app_id.argtypes = [c_int, c_void_p, c_char_p]
self.webview_create_with_app_id.restype = c_void_p
self.webview_destroy = self.lib.webview_destroy
self.webview_destroy.argtypes = [c_void_p]
@@ -114,10 +105,6 @@ class _WebviewLibrary:
self.webview_return = self.lib.webview_return
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
self.webview_get_native_handle = self.lib.webview_get_native_handle
self.webview_get_native_handle.argtypes = [c_void_p, c_int]
self.webview_get_native_handle.restype = c_void_p
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
self.CFUNCTYPE = CFUNCTYPE

View File

@@ -1,7 +1,6 @@
import functools
import json
import logging
import platform
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
@@ -12,10 +11,7 @@ from typing import TYPE_CHECKING, Any
from clan_lib.api import MethodRegistry, message_queue
from clan_lib.api.tasks import WebThread
from ._webview_ffi import (
_encode_c_string,
_webview_lib,
)
from ._webview_ffi import _encode_c_string, _webview_lib
from .webview_bridge import WebviewBridge
if TYPE_CHECKING:
@@ -36,21 +32,6 @@ class FuncStatus(IntEnum):
FAILURE = 1
class NativeHandleKind(IntEnum):
# Top-level window. @c GtkWindow pointer (GTK), @c NSWindow pointer (Cocoa)
# or @c HWND (Win32)
UI_WINDOW = 0
# Browser widget. @c GtkWidget pointer (GTK), @c NSView pointer (Cocoa) or
# @c HWND (Win32).
UI_WIDGET = 1
# Browser controller. @c WebKitWebView pointer (WebKitGTK), @c WKWebView
# pointer (Cocoa/WebKit) or @c ICoreWebView2Controller pointer
# (Win32/WebView2).
BROWSER_CONTROLLER = 2
@dataclass(frozen=True)
class Size:
width: int
@@ -65,7 +46,6 @@ class Webview:
size: Size | None = None
window: int | None = None
shared_threads: dict[str, WebThread] | None = None
app_id: str | None = None
# initialized later
_bridge: WebviewBridge | None = None
@@ -76,14 +56,7 @@ class Webview:
def _create_handle(self) -> None:
# Initialize the webview handle
with_debugger = True
# Use webview_create_with_app_id only on Linux if app_id is provided
if self.app_id and platform.system() == "Linux":
handle = _webview_lib.webview_create_with_app_id(
int(with_debugger), self.window, _encode_c_string(self.app_id)
)
else:
handle = _webview_lib.webview_create(int(with_debugger), self.window)
handle = _webview_lib.webview_create(int(with_debugger), self.window)
callbacks: dict[str, Callable[..., Any]] = {}
# Since we can't use object.__setattr__, we'll initialize differently
@@ -244,21 +217,6 @@ class Webview:
self._callbacks[name] = c_callback
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
def get_native_handle(
self, kind: NativeHandleKind = NativeHandleKind.UI_WINDOW
) -> int | None:
"""Get the native handle (platform-dependent).
Args:
kind: Handle kind - NativeHandleKind enum value
Returns:
Native handle as integer, or None if failed
"""
handle = _webview_lib.webview_get_native_handle(self.handle, kind.value)
return handle if handle else None
def unbind(self, name: str) -> None:
if name in self._callbacks:
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))

View File

@@ -11,11 +11,6 @@
gobject-introspection,
gtk4,
lib,
stdenv,
# macOS-specific dependencies
imagemagick,
makeWrapper,
libicns,
}:
let
source =
@@ -96,12 +91,7 @@ pythonRuntime.pkgs.buildPythonApplication {
# gtk4 deps
wrapGAppsHook4
]
++ runtimeDependencies
++ lib.optionals stdenv.hostPlatform.isDarwin [
imagemagick
makeWrapper
libicns
];
++ runtimeDependencies;
# The necessity of setting buildInputs and propagatedBuildInputs to the
# same values for your Python package within Nix largely stems from ensuring
@@ -158,113 +148,16 @@ pythonRuntime.pkgs.buildPythonApplication {
postInstall = ''
mkdir -p $out/${pythonRuntime.sitePackages}/clan_app/.webui
cp -r ${clan-app-ui}/lib/node_modules/@clan/ui/dist/* $out/${pythonRuntime.sitePackages}/clan_app/.webui
${lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
mkdir -p $out/share/icons/hicolor
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
''}
${lib.optionalString stdenv.hostPlatform.isDarwin ''
set -eu pipefail
# Create macOS app bundle structure
mkdir -p "$out/Applications/Clan App.app/Contents/"{MacOS,Resources}
# Create Info.plist
cat > "$out/Applications/Clan App.app/Contents/Info.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Clan App</string>
<key>CFBundleExecutable</key>
<string>Clan App</string>
<key>CFBundleIconFile</key>
<string>clan-app.icns</string>
<key>CFBundleIdentifier</key>
<string>org.clan.app</string>
<key>CFBundleName</key>
<string>Clan App</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Clan Protocol</string>
<key>CFBundleURLSchemes</key>
<array>
<string>clan</string>
</array>
</dict>
</array>
</dict>
</plist>
EOF
# Create app icon (convert PNG to ICNS using minimal approach to avoid duplicates)
# Create a temporary iconset directory structure
mkdir clan-app.iconset
# Create a minimal iconset with only essential, non-duplicate sizes
# Each PNG file should map to a unique ICNS type
cp ./clan_app/assets/white-favicons/16x16/apps/clan-app.png clan-app.iconset/icon_16x16.png
cp ./clan_app/assets/white-favicons/128x128/apps/clan-app.png clan-app.iconset/icon_128x128.png
# Use libicns png2icns tool to create proper ICNS file with minimal set
png2icns "$out/Applications/Clan App.app/Contents/Resources/clan-app.icns" \
clan-app.iconset/icon_16x16.png \
clan-app.iconset/icon_128x128.png
# Create PkgInfo file (standard requirement for macOS apps)
echo -n "APPL????" > "$out/Applications/Clan App.app/Contents/PkgInfo"
# Create the main executable script with proper process name
cat > "$out/Applications/Clan App.app/Contents/MacOS/Clan App" << EOF
#!/bin/bash
# Execute with the correct process name for app icon to appear
exec -a "\$0" "$out/bin/.clan-app-orig" "\$@"
EOF
chmod +x "$out/Applications/Clan App.app/Contents/MacOS/Clan App"
set +eu pipefail
''}
mkdir -p $out/share/icons/hicolor
cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor
'';
# TODO: If we start clan-app over the cli the process name is "python" and icons don't show up correctly on macOS
# I looked in how blender does it, but couldn't figure it out yet.
# They do an exec -a in their wrapper script, but that doesn't seem to work here.
# Don't leak python packages into a devshell.
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
postFixup = ''
rm $out/nix-support/propagated-build-inputs
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
set -eu pipefail
mv $out/bin/clan-app $out/bin/.clan-app-orig
# Create command line wrapper that executes the app bundle
cat > $out/bin/clan-app << EOF
#!/bin/bash
exec "$out/Applications/Clan App.app/Contents/MacOS/Clan App" "\$@"
EOF
chmod +x $out/bin/clan-app
set +eu pipefail
'';
checkPhase = ''
set -eu pipefail
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
@@ -278,7 +171,6 @@ pythonRuntime.pkgs.buildPythonApplication {
fc-list
PYTHONPATH= $out/bin/clan-app --help
set +eu pipefail
'';
desktopItems = [ desktop-file ];
}

View 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=";
};
archivoSemi_ttf = fetchurl {
url = "https://github.com/Omnibus-Type/Archivo/raw/b5d63988ce19d044d3e10362de730af00526b672/fonts/ttf/ArchivoSemiCondensed-Medium.ttf";
hash = "sha256-Kot1CvKqnXW1VZ7zX2wYZEziSA/l9J0gdfKkSdBxZ0w=";
commitMono_ttf = fetchurl {
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
};
in
@@ -66,5 +66,5 @@ runCommand "" { } ''
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
cp ${commitMono} $out/CommitMonoV143-VF.woff2
cp ${archivoSemi_ttf} $out/ArchivoSemiCondensed-Medium.ttf
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
''

View File

@@ -1,5 +1,10 @@
#!/usr/bin/env bash
if ! command -v xdg-mime &> /dev/null; then
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
fi
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-app"))')
if [ "$ALREADY_INSTALLED" = "true" ]; then
@@ -9,23 +14,9 @@ else
nix profile install .#clan-app
fi
# Check OS type
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
if ! command -v xdg-mime &> /dev/null; then
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
fi
# install desktop file
set -eou pipefail
DESKTOP_FILE_NAME=org.clan.app.desktop
# install desktop file on Linux
set -eou pipefail
DESKTOP_FILE_NAME=org.clan.app.desktop
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macOS detected."
mkdir -p ~/Applications
ln -sf ~/.nix-profile/Applications/Clan\ App.app ~/Applications
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f ~/Applications/Clan\ App.app
else
echo "Unsupported OS: $OSTYPE"
fi
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -eou pipefail
rsync --exclude result --exclude .direnv --exclude node_modules --delete -r ~/Projects/clan-core/pkgs/clan-app mac-mini-dev:~/clan-core/pkgs
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && nix build .#clan-app -Lv --show-trace"
ssh mac-mini-dev "cd \$HOME/clan-core/pkgs/clan-app && ./install-desktop.sh"

View File

@@ -91,8 +91,6 @@ mkShell {
pushd "$CLAN_CORE_PATH/pkgs/clan-app/ui"
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

View File

@@ -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");

View File

@@ -37,11 +37,6 @@ export const Menu = (props: {
"pointer-events": "auto",
}}
class={styles.list}
onContextMenu={(e) => {
// Prevent default context menu
e.preventDefault();
e.stopPropagation();
}}
>
<li
class={styles.item}

View File

@@ -1,4 +1,4 @@
.fieldset {
fieldset {
@apply flex flex-col w-full;
legend {

View File

@@ -35,10 +35,10 @@ export const Fieldset = (props: FieldsetProps) => {
: props.children;
return (
<div
<fieldset
role="group"
class={cx("fieldset", { inverted: props.inverted })}
aria-disabled={props.disabled || undefined}
class={cx({ inverted: props.inverted })}
disabled={props.disabled || false}
>
{props.legend && (
<legend>
@@ -69,6 +69,6 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography>
</div>
)}
</div>
</fieldset>
);
};

View File

@@ -1,50 +0,0 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { MachineTags, MachineTagsProps } from "./MachineTags";
import { createForm, setValue } from "@modular-forms/solid";
import { Button } from "../Button/Button";
const meta = {
title: "Components/MachineTags",
component: MachineTags,
} satisfies Meta<MachineTagsProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [formStore, { Field, Form }] = createForm<{ tags: string[] }>({
initialValues: { tags: ["nixos"] },
});
const handleSubmit = (values: { tags: string[] }) => {
console.log("submitting", values);
};
const readonly = ["nixos"];
const options = ["foo"];
return (
<Form onSubmit={handleSubmit}>
<Field name="tags" type="string[]">
{(field, props) => (
<MachineTags
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
name="Tags"
defaultOptions={options}
readonlyOptions={readonly}
readOnly={false}
defaultValue={field.value}
/>
)}
</Field>
<Button type="submit" hierarchy="primary">
Submit
</Button>
</Form>
);
},
};

View File

@@ -1,13 +1,6 @@
import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field";
import {
createEffect,
on,
createSignal,
For,
Show,
splitProps,
} from "solid-js";
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
import Icon from "../Icon/Icon";
import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
@@ -21,15 +14,16 @@ import styles from "./MachineTags.module.css";
export interface MachineTag {
value: string;
disabled?: boolean;
new?: boolean;
}
export type MachineTagsProps = FieldProps & {
name: string;
onChange: (values: string[]) => void;
defaultValue?: string[];
input: ComponentProps<"select">;
readOnly?: boolean;
disabled?: boolean;
required?: boolean;
defaultValue?: string[];
defaultOptions?: string[];
readonlyOptions?: string[];
};
@@ -50,12 +44,37 @@ const sortedOptions = (options: MachineTag[]) =>
const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options));
export const MachineTags = (props: MachineTagsProps) => {
const [local, rest] = splitProps(props, ["defaultValue"]);
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
>
<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>
);
};
// // convert default value string[] into MachineTag[]
export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const defaultValue = sortedAndUniqueOptions(
(local.defaultValue || []).map((value) => ({ value })),
(props.defaultValue || []).map((value) => ({ value })),
);
// convert default options string[] into MachineTag[]
@@ -69,51 +88,6 @@ export const MachineTags = (props: MachineTagsProps) => {
]),
);
const [selectedOptions, setSelectedOptions] =
createSignal<MachineTag[]>(defaultValue);
const handleToggle = (item: CollectionNode<MachineTag>) => () => {
setSelectedOptions((current) => {
const exists = current.find(
(option) => option.value === item.rawValue.value,
);
if (exists) {
return current.filter((option) => option.value !== item.rawValue.value);
}
return [...current, item.rawValue];
});
};
// customises how each option is displayed in the dropdown
const ItemComponent =
(inverted: boolean) => (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item
item={props.item}
class={cx(styles.listboxItem, {
[styles.listboxItemInverted]: inverted,
})}
onClick={handleToggle(props.item)}
>
<Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xs"
weight="bold"
inverted={inverted}
>
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class={styles.itemIndicator}>
<Icon icon="Checkmark" inverted={inverted} />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
let selectRef: HTMLSelectElement;
const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input
if (event.key === "Enter") {
@@ -122,49 +96,22 @@ export const MachineTags = (props: MachineTagsProps) => {
// get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement;
const trimmed = input.value.trim();
if (!trimmed) return;
if (input.value === "") return;
setAvailableOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
return [
...curr,
{
value: trimmed,
},
];
});
setSelectedOptions((curr) => {
if (curr.find((option) => option.value === trimmed)) {
return curr;
}
return [
...curr,
{
value: trimmed,
},
];
setAvailableOptions((options) => {
return options.map((option) => {
return {
...option,
new: undefined,
};
});
});
selectRef.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
selectRef.dispatchEvent(
new Event("change", { bubbles: true, cancelable: true }),
);
// reset the input value
input.value = "";
}
};
// Notify when selected options change
createEffect(
on(selectedOptions, (options) => {
props.onChange(options.map((o) => o.value));
}),
);
const align = () => {
if (props.readOnly) {
return "center";
@@ -179,16 +126,35 @@ export const MachineTags = (props: MachineTagsProps) => {
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(props.inverted || false)}
placeholder="Start typing a name and press enter"
onChange={() => {
// noop, we handle this via the selectedOptions signal
placeholder="Enter a tag name"
// triggerMode="focus"
removeOnBackspace={false}
defaultFilter={() => true}
onInput={(event) => {
const input = event.target as HTMLInputElement;
// as the user types in the input box, we maintain a "new" option
// in the list of available options
setAvailableOptions((options) => {
return [
// remove the old "new" entry
...options.filter((option) => !option.new),
// add the updated "new" entry
{ value: input.value, new: true },
];
});
}}
onBlur={() => {
// clear the in-progress "new" option from the list of available options
setAvailableOptions((options) => {
return options.filter((option) => !option.new);
});
}}
>
<Orienter orientation={props.orientation} align={align()}>
@@ -198,12 +164,7 @@ export const MachineTags = (props: MachineTagsProps) => {
{...props}
/>
<Combobox.HiddenSelect
multiple
ref={(el) => {
selectRef = el;
}}
/>
<Combobox.HiddenSelect {...props.input} multiple />
<Combobox.Control<MachineTag>
class={cx(styles.control, props.orientation)}
@@ -226,13 +187,7 @@ export const MachineTags = (props: MachineTagsProps) => {
icon={"Close"}
size="0.5rem"
inverted={inverted}
onClick={() =>
setSelectedOptions((curr) => {
return curr.filter(
(o) => o.value !== option.value,
);
})
}
onClick={() => state.remove(option)}
/>
)
}
@@ -265,6 +220,7 @@ export const MachineTags = (props: MachineTagsProps) => {
)}
</Combobox.Control>
</Orienter>
<Combobox.Portal>
<Combobox.Content
class={cx(styles.comboboxContent, {

View File

@@ -76,19 +76,6 @@ div.form-field {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
}
& > .start-component {
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
}
& > .end-component {
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
}
& > .start-component,
& > .end-component {
@apply size-fit;
}
}
&.s {
@@ -114,7 +101,7 @@ div.form-field {
}
& > .icon {
@apply w-[0.6875rem] h-[0.6875rem];
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
}
}
}

View File

@@ -1,8 +1,6 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import { TextInput, TextInputProps } from "@/src/components/Form/TextInput";
import Icon from "../Icon/Icon";
import { Button } from "@kobalte/core/button";
const Examples = (props: TextInputProps) => (
<div class="flex flex-col gap-8">
@@ -85,38 +83,16 @@ export const Tooltip: Story = {
},
};
export const WithIcon: Story = {
export const Icon: Story = {
args: {
...Tooltip.args,
startComponent: () => <Icon icon="EyeClose" color="quaternary" inverted />,
},
};
export const WithStartComponent: Story = {
args: {
...Tooltip.args,
startComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeClose" color="quaternary" {...props} />
</Button>
),
},
};
export const WithEndComponent: Story = {
args: {
...Tooltip.args,
endComponent: (props: { inverted?: boolean }) => (
<Button>
<Icon icon="EyeOpen" color="quaternary" {...props} />
</Button>
),
icon: "Checkmark",
},
};
export const Ghost: Story = {
args: {
...WithIcon.args,
...Icon.args,
ghost: true,
},
};
@@ -130,14 +106,14 @@ export const Invalid: Story = {
export const Disabled: Story = {
args: {
...WithIcon.args,
...Icon.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...WithIcon.args,
...Icon.args,
readOnly: true,
defaultValue: "14/05/02",
},

View File

@@ -11,20 +11,12 @@ import "./TextInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import {
Component,
createEffect,
createSignal,
onMount,
splitProps,
} from "solid-js";
import { splitProps } from "solid-js";
export type TextInputProps = FieldProps &
TextFieldRootProps & {
icon?: IconVariant;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
startComponent?: Component<Pick<FieldProps, "inverted">>;
endComponent?: Component<Pick<FieldProps, "inverted">>;
};
export const TextInput = (props: TextInputProps) => {
@@ -36,39 +28,6 @@ export const TextInput = (props: TextInputProps) => {
"ghost",
]);
let inputRef: HTMLInputElement | undefined;
let startComponentRef: HTMLDivElement | undefined;
let endComponentRef: HTMLDivElement | undefined;
const [startComponentSize, setStartComponentSize] = createSignal({
width: 0,
height: 0,
});
const [endComponentSize, setEndComponentSize] = createSignal({
width: 0,
height: 0,
});
onMount(() => {
if (startComponentRef) {
const rect = startComponentRef.getBoundingClientRect();
setStartComponentSize({ width: rect.width, height: rect.height });
}
if (endComponentRef) {
const rect = endComponentRef.getBoundingClientRect();
setEndComponentSize({ width: rect.width, height: rect.height });
}
});
createEffect(() => {
if (inputRef) {
const padding = props.size == "s" ? 6 : 8;
inputRef.style.paddingLeft = `${startComponentSize().width + padding * 2}px`;
inputRef.style.paddingRight = `${endComponentSize().width + padding * 2}px`;
}
});
return (
<TextField
class={cx(
@@ -91,11 +50,6 @@ export const TextInput = (props: TextInputProps) => {
{...props}
/>
<div class="input-container">
{props.startComponent && !props.readOnly && (
<div ref={startComponentRef} class="start-component">
{props.startComponent({ inverted: props.inverted })}
</div>
)}
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
@@ -104,17 +58,9 @@ export const TextInput = (props: TextInputProps) => {
/>
)}
<TextField.Input
ref={inputRef}
{...props.input}
class={cx({
"has-icon": props.icon && !props.readOnly,
})}
classList={{ "has-icon": props.icon && !props.readOnly }}
/>
{props.endComponent && !props.readOnly && (
<div ref={endComponentRef} class="end-component">
{props.endComponent({ inverted: props.inverted })}
</div>
)}
</div>
</Orienter>
</TextField>

View File

@@ -1,6 +1,6 @@
.loader {
@apply relative;
@apply size-full;
@apply w-4 h-4;
&.primary {
& > div.wrapper > div.parent,
@@ -15,18 +15,6 @@
background: #0051ff;
}
}
&.sizeDefault {
@apply size-4;
}
&.sizeLarge {
@apply size-8;
}
&.sizeExtraLarge {
@apply size-12;
}
}
.wrapper {

View File

@@ -1,17 +1,9 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Loader, LoaderProps } from "@/src/components/Loader/Loader";
const LoaderExamples = (props: LoaderProps) => (
<div class="grid grid-cols-8">
<Loader {...props} size="default" />
<Loader {...props} size="l" />
<Loader {...props} size="xl" />
</div>
);
const meta: Meta<LoaderProps> = {
title: "Components/Loader",
component: LoaderExamples,
component: Loader,
};
export default meta;

View File

@@ -7,23 +7,15 @@ export type Hierarchy = "primary" | "secondary";
export interface LoaderProps {
hierarchy?: Hierarchy;
class?: string;
size?: "default" | "l" | "xl";
}
export const Loader = (props: LoaderProps) => {
const size = () => props.size || "default";
return (
<div
class={cx(
styles.loader,
styles[props.hierarchy || "primary"],
props.class,
{
[styles.sizeDefault]: size() === "default",
[styles.sizeLarge]: size() === "l",
[styles.sizeExtraLarge]: size() === "xl",
},
)}
>
<div class={styles.wrapper}>

View File

@@ -20,9 +20,6 @@ export const MachineStatus = (props: MachineStatusProps) => {
// we will use css transform in the typography component to capitalize
const statusText = () => props.status?.replaceAll("_", " ");
// our implementation of machine status in the backend needs more time to bake, so for now we only display if a
// machine is not installed
return (
<Switch>
<Match when={!status()}>
@@ -31,6 +28,9 @@ export const MachineStatus = (props: MachineStatusProps) => {
<Match when={status()}>
<Badge
class={cx("machine-status", {
online: status() == "online",
offline: status() == "offline",
"out-of-sync": status() == "out_of_sync",
"not-installed": status() == "not_installed",
})}
textValue={status()}

View File

@@ -1,3 +1,3 @@
.sidebar {
@apply w-60 border-none z-10 h-full flex flex-col rounded-b-md overflow-hidden;
@apply w-60 border-none z-10 h-full flex flex-col;
}

View File

@@ -1,15 +1,14 @@
div.sidebar-body {
@apply py-4 px-2;
/* full - (y padding) */
height: calc(100% - 2rem);
@apply py-4 px-2 h-full;
@apply border border-inv-3 rounded-bl-md rounded-br-md;
/* TODO: This is weird, we shouldn't disable native browser features, a11y impacts incomming */
&::-webkit-scrollbar {
display: none;
}
overflow-y: auto;
scrollbar-width: none;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
linear-gradient(
@@ -21,14 +20,13 @@ div.sidebar-body {
@apply backdrop-blur-sm;
.accordion {
@apply w-full mb-4 h-full flex flex-col justify-start gap-4;
@apply w-full mb-4;
&:last-child {
@apply mb-0;
}
& > .item {
max-height: 50%;
&:last-child {
@apply mb-0;
}
@@ -60,13 +58,9 @@ div.sidebar-body {
}
& > .content {
@apply flex flex-col;
@apply overflow-hidden flex flex-col;
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
max-height: calc(100% - 24px);
overflow-y: auto;
scrollbar-width: none;
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
&[data-expanded] {

View File

@@ -5,12 +5,11 @@ import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
import { For, Show } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, buildServicePath } from "@/src/hooks/clan";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar";
import { Button } from "../Button/Button";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { Instance } from "@/src/workflows/Service/models";
interface MachineProps {
clanURI: string;
@@ -34,19 +33,19 @@ const MachineRoute = (props: MachineProps) => {
size="xs"
weight="bold"
color="primary"
inverted
inverted={true}
>
{props.name}
</Typography>
<MachineStatus status={status()} />
</div>
<div class="flex w-full flex-row items-center gap-1">
<Icon icon="Flash" size="0.75rem" inverted color="tertiary" />
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
<Typography
hierarchy="label"
family="mono"
size="s"
inverted
inverted={true}
color="primary"
>
{props.serviceCount}
@@ -57,13 +56,18 @@ const MachineRoute = (props: MachineProps) => {
);
};
const Machines = () => {
const ctx = useClanContext();
if (!ctx) {
throw new Error("ClanContext not found");
}
export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI();
const clanURI = ctx.clanURI;
const ctx = useClanContext();
const sectionLabels = (props.staticSections || []).map(
(section) => section.title,
);
// controls which sections are open by default
// we want them all to be open by default
const defaultAccordionValues = ["your-machines", ...sectionLabels];
const machines = () => {
if (!ctx.machinesQuery.isSuccess) {
@@ -74,173 +78,6 @@ const Machines = () => {
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
@@ -248,8 +85,66 @@ export const SidebarBody = (props: SidebarProps) => {
multiple
defaultValue={defaultAccordionValues}
>
<Machines />
<Services />
<Accordion.Item class="item" value="your-machines">
<Accordion.Header class="header">
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted={true}
color="tertiary"
>
Your Machines
</Typography>
<Icon
icon="CaretDown"
color="tertiary"
inverted={true}
size="0.75rem"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<Show
when={machines()}
fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
No machines yet
</Typography>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.name || id}
serviceCount={0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content>
</Accordion.Item>
<For each={props.staticSections}>
{(section) => (
@@ -261,7 +156,7 @@ export const SidebarBody = (props: SidebarProps) => {
hierarchy="label"
family="mono"
size="xs"
inverted
inverted={true}
color="tertiary"
>
{section.title}
@@ -269,7 +164,7 @@ export const SidebarBody = (props: SidebarProps) => {
<Icon
icon="CaretDown"
color="tertiary"
inverted
inverted={true}
size="0.75rem"
/>
</Accordion.Trigger>
@@ -284,7 +179,7 @@ export const SidebarBody = (props: SidebarProps) => {
size="xs"
weight="bold"
color="primary"
inverted
inverted={true}
>
{link.label}
</Typography>

View File

@@ -13,7 +13,6 @@ import * as v from "valibot";
import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
type Story = StoryObj<SidebarPaneProps>;
@@ -138,21 +137,18 @@ export const Default: Story = {
console.log("saving tags", values);
}}
>
{({ editing, Field, formStore }) => (
{({ editing, Field }) => (
<Field name="tags" type="string[]">
{(field, props) => (
{(field, input) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</Field>

View File

@@ -2,7 +2,6 @@ import { createSignal, JSX, Show } from "solid-js";
import {
createForm,
FieldValues,
FormStore,
getErrors,
Maybe,
PartialValues,
@@ -26,7 +25,6 @@ export interface SidebarSectionFormProps<FormValues extends FieldValues> {
children: (ctx: {
editing: boolean;
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
formStore: FormStore<FormValues>;
}) => JSX.Element;
}
@@ -53,8 +51,6 @@ export function SidebarSectionForm<
};
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
console.log("Submitting SidebarForm", values);
await props.onSubmit(values);
setEditing(false);
};
@@ -113,7 +109,7 @@ export function SidebarSectionForm<
</Typography>
</div>
</Show>
{props.children({ editing: editing(), Field, formStore })}
{props.children({ editing: editing(), Field })}
</div>
</div>
</Form>

View File

@@ -5,7 +5,6 @@ import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css";
import { Alert } from "../Alert/Alert";
import { useClanContext } from "@/src/routes/Clan/Clan";
export interface SidebarSectionInstallProps {
clanURI: string;
@@ -13,8 +12,8 @@ export interface SidebarSectionInstallProps {
}
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
const ctx = useClanContext();
const query = useMachineStateQuery(props.clanURI, props.machineName);
const [showInstall, setShowModal] = createSignal(false);
return (
@@ -33,12 +32,7 @@ export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
<InstallModal
open={showInstall()}
machineName={useMachineName()}
onClose={async () => {
// refresh some queries
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
setShowModal(false);
}}
onClose={() => setShowModal(false)}
/>
</Show>
</div>

View File

@@ -1,46 +0,0 @@
import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button";
import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css";
import { UpdateModal } from "@/src/workflows/InstallMachine/UpdateMachine";
import { useClanContext } from "@/src/routes/Clan/Clan";
export interface SidebarSectionUpdateProps {
clanURI: string;
machineName: string;
}
export const SidebarSectionUpdate = (props: SidebarSectionUpdateProps) => {
const ctx = useClanContext();
const query = useMachineStateQuery(props.clanURI, props.machineName);
const [showUpdate, setShowUpdate] = createSignal(false);
return (
<Show when={query.isSuccess && query.data.status !== "not_installed"}>
<div class={styles.install}>
<Button
hierarchy="primary"
size="s"
onClick={() => setShowUpdate(true)}
>
Update machine
</Button>
<Show when={showUpdate()}>
<UpdateModal
open={showUpdate()}
machineName={useMachineName()}
onClose={async () => {
// refresh some queries
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
setShowUpdate(false);
}}
/>
</Show>
</div>
</Show>
);
};

View File

@@ -1,6 +1,6 @@
import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { Params, Navigator, useParams, useSearchParams } from "@solidjs/router";
import { Params, Navigator, useParams } from "@solidjs/router";
export const encodeBase64 = (value: string) => window.btoa(value);
export const decodeBase64 = (value: string) => window.atob(value);
@@ -30,47 +30,6 @@ export const buildClanPath = (clanURI: string) => {
export const buildMachinePath = (clanURI: string, name: string) =>
buildClanPath(clanURI) + "/machines/" + name;
export const buildServicePath = (props: {
clanURI: string;
id: string;
module: {
name: string;
input?: string | null | undefined;
};
}) => {
const { clanURI, id, module } = props;
const moduleName = encodeBase64(module.name);
const idEncoded = encodeBase64(id);
const result =
buildClanPath(clanURI) +
`/services/${moduleName}/${idEncoded}` +
(module.input ? `?input=${module.input}` : "");
return result;
};
export const useServiceParams = () => {
const params = useParams<{
name?: string;
id?: string;
}>();
const [search] = useSearchParams<{ input?: string }>();
if (!params.name || !params.id) {
console.error("Service params not found", params, window.location.pathname);
throw new Error("Service params not found");
}
return {
name: decodeBase64(params.name),
id: decodeBase64(params.id),
input: search.input,
};
};
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
const path = buildClanPath(clanURI);
console.log("Navigating to clan", clanURI, path);
@@ -105,21 +64,7 @@ export const machineNameParam = (params: Params) => {
return params.machineName;
};
export const inputParam = (params: Params) => params.input;
export const nameParam = (params: Params) => params.name;
export const idParam = (params: Params) => params.id;
export const useMachineName = (): string => machineNameParam(useParams());
export const useInputParam = (): string => inputParam(useParams());
export const useNameParam = (): string => nameParam(useParams());
export const maybeUseIdParam = (): string | null => {
const params = useParams();
if (params.id === undefined) {
return null;
}
return idParam(params);
};
export const maybeUseMachineName = (): string | null => {
const params = useParams();

View File

@@ -25,9 +25,6 @@ export type MachineStatus = MachineState["status"];
export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">;
export type ListServiceModules = SuccessData<"list_service_modules">;
export type ListServiceInstances = SuccessData<"list_service_instances">;
export interface MachineDetail {
tags: Tags;
machine: Machine;
@@ -50,7 +47,7 @@ export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListMachines>(() => ({
queryKey: [...clanKey(clanURI), "machines"],
queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => {
const api = client.fetch("list_machines", {
flake: {
@@ -67,16 +64,10 @@ export const useMachinesQuery = (clanURI: string) => {
}));
};
export const machineKey = (clanUri: string, machineName: string) => [
...clanKey(clanUri),
"machine",
encodeBase64(machineName),
];
export const useMachineQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineDetail>(() => ({
queryKey: [machineKey(clanURI, machineName)],
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => {
const [tagsCall, machineCall, schemaCall] = [
client.fetch("list_tags", {
@@ -131,7 +122,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
export const useTags = (clanURI: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: [...clanKey(clanURI), "tags"],
queryKey: ["clans", encodeBase64(clanURI), "tags"],
queryFn: async () => {
const apiCall = client.fetch("list_tags", {
flake: {
@@ -151,7 +142,8 @@ export const useTags = (clanURI: string) => {
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineState>(() => ({
queryKey: [...machineKey(clanURI, machineName), "state"],
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
staleTime: 60_000, // 1 minute stale time
queryFn: async () => {
const apiCall = client.fetch("get_machine_state", {
machine: {
@@ -174,61 +166,13 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
}));
};
export const useServiceModulesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListServiceModules>(() => ({
queryKey: [...clanKey(clanURI), "service_modules"],
queryFn: async () => {
const call = client.fetch("list_service_modules", {
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(
"Error fetching service modules: " + result.errors[0].message,
);
}
return result.data;
},
}));
};
export const useServiceInstancesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListServiceInstances>(() => ({
queryKey: [...clanKey(clanURI), "service_instances"],
queryFn: async () => {
const call = client.fetch("list_service_instances", {
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(
"Error fetching service instances: " + result.errors[0].message,
);
}
return result.data;
},
}));
};
export const useMachineDetailsQuery = (
clanURI: string,
machineName: string,
) => {
const client = useApiClient();
return useQuery<MachineDetails>(() => ({
queryKey: [machineKey(clanURI, machineName), "details"],
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
queryFn: async () => {
const call = client.fetch("get_machine_details", {
machine: {
@@ -258,7 +202,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ClanDetails>(() => ({
queryKey: [...clanKey(clanURI), "details"],
queryKey: ["clans", encodeBase64(clanURI), "details"],
persister: ClanDetailsPersister.persisterFn,
queryFn: async () => {
const args = {
@@ -309,8 +253,7 @@ export const useClanListQuery = (
return useQueries(() => ({
queries: clanURIs.map((clanURI) => {
// @BMG: Is duplicating query key intentional?
const queryKey = [...clanKey(clanURI), "details"];
const queryKey = ["clans", encodeBase64(clanURI), "details"];
return {
// eslint-disable-next-line @tanstack/query/exhaustive-deps
@@ -379,7 +322,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
const client = useApiClient();
return useQuery<MachineFlashOptions>(() => ({
queryKey: ["flash_options"],
queryKey: ["clans", "machine_flash_options"],
queryFn: async () => {
const call = client.fetch("get_machine_flash_options", {});
const result = await call.result;
@@ -543,7 +486,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
export const useServiceModules = (clanUri: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: [...clanKey(clanUri), "service_modules"],
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
queryFn: async () => {
const call = client.fetch("list_service_modules", {
flake: {
@@ -563,14 +506,12 @@ export const useServiceModules = (clanUri: string) => {
}));
};
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
export type ServiceInstances = SuccessData<"list_service_instances">;
export const useServiceInstances = (clanUri: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: [...clanKey(clanUri), "service_instances"],
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
queryFn: async () => {
const call = client.fetch("list_service_instances", {
flake: {

View File

@@ -1,4 +1,4 @@
import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import {
Component,
createContext,
@@ -17,15 +17,13 @@ import {
useClanURI,
useMachineName,
} from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
import {
ClanDetails,
ListServiceInstances,
MachinesQueryResult,
useClanDetailsQuery,
useClanListQuery,
useMachinesQuery,
useServiceInstancesQuery,
} from "@/src/hooks/queries";
import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store";
@@ -35,27 +33,37 @@ 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";
export type WorldMode = "default" | "select" | "service" | "create" | "move";
interface ClanContextProps {
clanURI: string;
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetails>;
otherClanQueries: UseQueryResult<ClanDetails>[];
allClansQueries: UseQueryResult<ClanDetails>[];
isLoading(): boolean;
isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
}
function createClanContext(
clanURI: string,
machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetails>[],
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
) {
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
const [showAddMachine, setShowAddMachine] = createSignal(false);
const navigate = useNavigate();
const location = useLocation();
const allClansQueries = [activeClanQuery, ...otherClanQueries];
const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
const allQueries = [machinesQuery, ...allClansQueries];
return {
clanURI,
@@ -63,23 +71,14 @@ 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<
ReturnType<typeof createClanContext> | undefined
>();
const ClanContext = createContext<ClanContextProps>();
export const useClanContext = () => {
const ctx = useContext(ClanContext);
@@ -105,14 +104,12 @@ export const Clan: Component<RouteSectionProps> = (props) => {
);
const machinesQuery = useMachinesQuery(clanURI);
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
const ctx = createClanContext(
clanURI,
machinesQuery,
activeClanQuery,
otherClanQueries,
serviceInstancesQuery,
);
return (
@@ -135,6 +132,8 @@ 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;
@@ -195,7 +194,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
}),
);
const location = useLocation();
const client = useApiClient();
const handleSubmitService: SubmitServiceHandler = async (
instance,
action,
) => {
console.log(action, "Instance", instance);
if (action !== "create") {
toast.error("Only creating new services is supported");
return;
}
const call = client.fetch("create_service_instance", {
flake: {
identifier: ctx.clanURI,
},
module_ref: instance.module,
roles: instance.roles,
});
const result = await call.result;
if (result.status === "error") {
toast.error("Error creating service instance");
console.error("Error creating service instance", result.errors);
}
toast.success("Created");
setShowService(false);
setWorldMode("select");
};
createEffect(
on(worldMode, (mode) => {
if (mode === "service") {
setShowService(true);
} else {
// TODO: request soft close instead of forced close
setShowService(false);
}
}),
);
return (
<>
@@ -231,19 +268,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery}
toolbarPopup={
<Show when={ctx.worldMode() === "service"}>
<Show
when={location.pathname.includes("/services/")}
fallback={
<SelectService
onClose={() => {
ctx.setWorldMode("select");
}}
/>
}
>
{props.children}
</Show>
<Show when={showService()}>
<ServiceWorkflow
handleSubmit={handleSubmitService}
onClose={() => {
setShowService(false);
setWorldMode("select");
currentPromise()?.resolve({ id: "0" });
}}
/>
</Show>
}
onCreate={onCreate}

View File

@@ -6,11 +6,10 @@ import { SectionGeneral } from "./SectionGeneral";
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags";
import { callApi } from "@/src/hooks/api";
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
import styles from "./Machine.module.css";
import { SectionServices } from "@/src/routes/Machine/SectionServices";
import { SidebarSectionUpdate } from "@/src/components/Sidebar/SidebarSectionUpdate";
export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate();
@@ -21,16 +20,13 @@ 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,
@@ -61,13 +57,8 @@ export const Machine = (props: RouteSectionProps) => {
clanURI={clanURI}
machineName={useMachineName()}
/>
<SidebarSectionUpdate
clanURI={clanURI}
machineName={useMachineName()}
/>
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
<SectionServices />
</>
);
};
@@ -78,19 +69,16 @@ export const Machine = (props: RouteSectionProps) => {
<SidebarPane
title={useMachineName()}
onClose={onClose}
// the implementation of remote machine status in the backend needs more time to bake, so for now we remove it and
// present the user with the ability to install or update a machines based on `installedAt` in the inventory.json
//
// subHeader={
// <Show when={useMachineName()} keyed>
// <SidebarMachineStatus
// clanURI={clanURI}
// machineName={useMachineName()}
// />
// </Show>
// }
subHeader={
<Show when={useMachineName()} keyed>
<SidebarMachineStatus
clanURI={clanURI}
machineName={useMachineName()}
/>
</Show>
}
>
{Sections()}
{sections()}
</SidebarPane>
</div>
</Show>

View File

@@ -1,41 +0,0 @@
.sectionServices {
@apply overflow-hidden flex flex-col;
@apply bg-inv-4 rounded-md;
nav * {
@apply outline-none;
}
nav > a {
@apply block w-full px-2 py-1.5 min-h-7 my-2 rounded-md;
&:first-child {
@apply mt-0;
}
&:last-child {
@apply mb-0;
}
&:focus-visible {
background: linear-gradient(
90deg,
theme(colors.secondary.900),
60%,
theme(colors.secondary.600) 100%
);
}
&:hover {
@apply bg-inv-acc-2;
}
&:active {
@apply bg-inv-acc-3;
}
&.active {
@apply bg-inv-acc-2;
}
}
}

View File

@@ -1,53 +0,0 @@
import { SidebarSection } from "@/src/components/Sidebar/SidebarSection";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { For, Show } from "solid-js";
import { useMachineName } from "@/src/hooks/clan";
import { ServiceRoute } from "@/src/components/Sidebar/SidebarBody";
import styles from "./SectionServices.module.css";
export const SectionServices = () => {
const ctx = useClanContext();
const services = () => {
if (!(ctx.machinesQuery.isSuccess && ctx.serviceInstancesQuery.isSuccess)) {
return [];
}
const machineName = useMachineName();
if (!ctx.machinesQuery.data[machineName]) {
return [];
}
return (ctx.machinesQuery.data[machineName].instance_refs ?? [])
.map((id) => {
const instance = ctx.serviceInstancesQuery.data?.[id];
if (!instance) {
throw new Error(`Service instance ${id} not found`);
}
const module = instance.module;
return {
id,
instance,
label: module.name == id ? module.name : `${module.name} (${id})`,
};
})
.sort((a, b) => a.label.localeCompare(b.label));
};
return (
<Show when={ctx.serviceInstancesQuery.isSuccess}>
<SidebarSection title="Services">
<div class={styles.sectionServices}>
<nav>
<For each={services()}>
{(instance) => (
<ServiceRoute clanURI={ctx.clanURI} {...instance} />
)}
</For>
</nav>
</div>
</SidebarSection>
</Show>
);
};

View File

@@ -5,7 +5,6 @@ import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm"
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
const schema = v.object({
tags: v.pipe(v.optional(v.array(v.string()))),
@@ -33,7 +32,7 @@ export const SectionTags = (props: SectionTags) => {
const options = () => {
if (!machineQuery.isSuccess) {
return [];
return [[], []];
}
// these are static values or values which have been configured in nix and
@@ -59,7 +58,7 @@ export const SectionTags = (props: SectionTags) => {
onSubmit={props.onSubmit}
initialValues={initialValues()}
>
{({ editing, Field, formStore }) => (
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="tags" type="string[]">
{(field, input) => (
@@ -73,10 +72,7 @@ export const SectionTags = (props: SectionTags) => {
defaultValue={field.value}
defaultOptions={options()[0]}
readonlyOptions={options()[1]}
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
input={input}
/>
)}
</Field>

View File

@@ -1,59 +0,0 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { ServiceWorkflow } from "@/src/workflows/Service/Service";
import { SubmitServiceHandler } from "@/src/workflows/Service/models";
import { buildClanPath } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
import { useQueryClient } from "@tanstack/solid-query";
import { clanKey } from "@/src/hooks/queries";
import { onMount } from "solid-js";
export const Service = (props: RouteSectionProps) => {
const ctx = useClanContext();
const navigate = useNavigate();
const client = useApiClient();
const queryClient = useQueryClient();
onMount(() => {
ctx.setWorldMode("service");
});
const handleSubmit: SubmitServiceHandler = async (instance, action) => {
console.log("Service submitted", instance, action);
if (action !== "create") {
console.warn("Updating service instances is not supported yet");
return;
}
const call = client.fetch("create_service_instance", {
flake: {
identifier: ctx.clanURI,
},
module_ref: instance.module,
roles: instance.roles,
});
const result = await call.result;
if (result.status === "error") {
console.error("Error creating service instance", result.errors);
}
queryClient.invalidateQueries({
queryKey: clanKey(ctx.clanURI),
});
ctx.setWorldMode("select");
};
const handleClose = () => {
console.log("Service closed, navigating back");
navigate(buildClanPath(ctx.clanURI), { replace: true });
ctx.setWorldMode("select");
};
return <ServiceWorkflow handleSubmit={handleSubmit} onClose={handleClose} />;
};

View File

@@ -2,7 +2,6 @@ import type { RouteDefinition } from "@solidjs/router/dist/types";
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
import { Clan } from "@/src/routes/Clan/Clan";
import { Machine } from "@/src/routes/Machine/Machine";
import { Service } from "@/src/routes/Service/Service";
export const Routes: RouteDefinition[] = [
{
@@ -31,15 +30,6 @@ export const Routes: RouteDefinition[] = [
{
path: "/machines/:machineName",
component: Machine,
children: [
{
path: "/",
},
],
},
{
path: "/services/:name/:id",
component: Service,
},
],
},

View File

@@ -1,10 +1,11 @@
import * as THREE from "three";
import { ObjectRegistry } from "./ObjectRegistry";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Accessor, createEffect, createRoot, on } from "solid-js";
import { renderLoop } from "./RenderLoop";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
import { FontLoader } from "three/examples/jsm/Addons";
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
// @ts-expect-error: No types for troika-three-text
import { Text } from "troika-three-text";
import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
// Constants
const BASE_SIZE = 0.9;
@@ -22,71 +23,6 @@ const BASE_EMISSIVE = 0x0c0c0c;
const BASE_SELECTED_COLOR = 0x69b0e3;
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
export function createMachineMesh() {
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
const material = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
shininess: 100,
transparent: true,
});
const cubeMesh = new THREE.Mesh(geometry, material);
cubeMesh.castShadow = true;
cubeMesh.receiveShadow = true;
cubeMesh.name = "cube";
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
const { baseMesh, baseMaterial } = createCubeBase(
BASE_COLOR,
BASE_EMISSIVE,
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
);
return {
cubeMesh,
baseMesh,
baseMaterial,
geometry,
material,
};
}
export function createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
geometry: THREE.BoxGeometry,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
color,
emissive,
transparent: true,
opacity: 1,
});
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
baseMesh.receiveShadow = false;
return { baseMesh, baseMaterial };
}
// Function to build rounded rect shape
export function roundedRectShape(w: number, h: number, r: number) {
const shape = new THREE.Shape();
const x = -w / 2;
const y = -h / 2;
shape.moveTo(x + r, y);
shape.lineTo(x + w - r, y);
shape.quadraticCurveTo(x + w, y, x + w, y + r);
shape.lineTo(x + w, y + h - r);
shape.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
shape.lineTo(x + r, y + h);
shape.quadraticCurveTo(x, y + h, x, y + h - r);
shape.lineTo(x, y + r);
shape.quadraticCurveTo(x, y, x + r, y);
return shape;
}
export class MachineRepr {
public id: string;
public group: THREE.Group;
@@ -110,21 +46,31 @@ 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,
});
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
this.cubeMesh = cubeMesh;
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
this.cubeMesh.castShadow = true;
this.cubeMesh.receiveShadow = true;
this.cubeMesh.userData = { id };
this.cubeMesh.name = "cube";
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
this.baseMesh = baseMesh;
this.baseMesh = this.createCubeBase(
BASE_COLOR,
BASE_EMISSIVE,
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
);
this.baseMesh.name = "base";
this.geometry = geometry;
this.material = material;
const label = this.createLabel(id);
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
color: BASE_COLOR,
color: BASE_COLOR, // any color you like
roughness: 1,
metalness: 0,
transparent: true,
@@ -158,6 +104,8 @@ export class MachineRepr {
const highlightedGroups = groups
.filter(([, ids]) => ids.has(this.id))
.map(([name]) => name);
// console.log("MachineRepr effect", id, highlightedGroups);
// Update cube
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
@@ -174,6 +122,9 @@ export class MachineRepr {
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
);
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
// );
renderLoop.requestRender();
},
@@ -198,85 +149,45 @@ export class MachineRepr {
renderLoop.requestRender();
}
private createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
geometry: THREE.BoxGeometry,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
color,
emissive,
transparent: true,
opacity: 1,
});
const base = new THREE.Mesh(geometry, baseMaterial);
base.position.set(0, BASE_HEIGHT / 2, 0);
base.receiveShadow = false;
return base;
}
private createLabel(id: string) {
const group = new THREE.Group();
// 0x162324
// const text = new Text();
// text.text = id;
// text.font = ttf;
// text.fontSize = 0.1;
// text.color = 0xffffff;
// text.anchorX = "center";
// text.anchorY = "middle";
// text.position.set(0, 0, 0.01);
// text.outlineWidth = 0.005;
// text.outlineColor = 0x162324;
// text.sync(() => {
// renderLoop.requestRender();
// });
const 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 textMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
// 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 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;
return text;
}
dispose(scene: THREE.Scene) {
@@ -286,13 +197,12 @@ export class MachineRepr {
this.geometry.dispose();
this.material.dispose();
this.group.clear();
for (const child of this.cubeMesh.children) {
if (child instanceof THREE.Mesh)
(child.material as THREE.Material).dispose();
if (child instanceof CSS2DObject) child.element.remove();
if (child instanceof THREE.Object3D) child.remove();
}
(this.baseMesh.material as THREE.Material).dispose();

View File

@@ -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-30 left-1/2;
@apply absolute bottom-10 z-10 w-full;
@apply flex justify-center items-center;
}

View File

@@ -25,13 +25,7 @@ import { MachineManager } from "./MachineManager";
import cx from "classnames";
import { Portal } from "solid-js/web";
import { Menu } from "../components/ContextMenu/ContextMenu";
import {
clearHighlight,
highlightGroups,
setHighlightGroups,
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
import { clearHighlight, setHighlightGroups } from "./highlightStore";
function intersectMachines(
event: MouseEvent,
@@ -39,7 +33,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,
@@ -50,10 +44,7 @@ function intersectMachines(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
return {
machines: intersects.map((i) => i.object.userData.id),
intersection: intersects,
};
return intersects.map((i) => i.object.userData.id);
}
function garbageCollectGroup(group: THREE.Group) {
@@ -95,6 +86,12 @@ 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 }>;
@@ -106,8 +103,6 @@ export function CubeScene(props: {
clanURI: string;
toolbarPopup?: JSX.Element;
}) {
const ctx = useClanContext();
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.OrthographicCamera;
@@ -118,7 +113,6 @@ 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();
@@ -129,17 +123,12 @@ 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({
@@ -311,12 +300,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);
@@ -395,23 +384,6 @@ 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);
@@ -437,7 +409,7 @@ export function CubeScene(props: {
updateCameraInfo();
createEffect(
on(ctx.worldMode, (mode) => {
on(worldMode, (mode) => {
if (mode === "create") {
actionBase!.visible = true;
} else {
@@ -449,7 +421,7 @@ export function CubeScene(props: {
const registry = new ObjectRegistry();
machineManager = new MachineManager(
const machineManager = new MachineManager(
scene,
registry,
props.sceneStore,
@@ -463,7 +435,7 @@ export function CubeScene(props: {
// - Select/deselects a cube in mode
// - Creates a new cube in "create" mode
const onClick = (event: MouseEvent) => {
if (ctx.worldMode() === "create") {
if (worldMode() === "create") {
props
.onCreate()
.then(({ id }) => {
@@ -481,16 +453,17 @@ export function CubeScene(props: {
.finally(() => {
if (actionBase) actionBase.visible = false;
ctx.setWorldMode("select");
setWorldMode("default");
});
}
if (ctx.worldMode() === "move") {
if (worldMode() === "move") {
console.log("sanpped");
const currId = menuIntersection().at(0);
const pos = cursorPosition();
if (!currId || !pos) return;
props.setMachinePos(currId, pos);
ctx.setWorldMode("select");
setWorldMode("select");
clearHighlight("move");
}
@@ -504,20 +477,18 @@ export function CubeScene(props: {
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
console.log("Intersects:", intersects);
if (intersects.length > 0) {
const id = intersects.find((i) => i.object.userData?.id)?.object
.userData.id;
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id;
if (!id) return;
if (worldMode() === "select") props.onSelect(new Set<string>([id]));
if (ctx.worldMode() === "select") props.onSelect(new Set<string>([id]));
console.log("Clicked on machine", id);
emitMachineClick(id); // notify subscribers
} else {
emitMachineClick(null);
if (ctx.worldMode() === "select") props.onSelect(new Set<string>());
if (worldMode() === "select") props.onSelect(new Set<string>());
}
};
@@ -549,73 +520,24 @@ export function CubeScene(props: {
};
const handleMouseDown = (e: MouseEvent) => {
const { 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(machines);
setMenuIntersection(intersection);
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);
@@ -660,55 +582,21 @@ export function CubeScene(props: {
});
});
const snapToGrid = (point: THREE.Vector3) => {
if (!props.sceneStore) return;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
0,
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
// Skip snapping if there's already a cube at this position
const positions = Object.entries(props.sceneStore());
const intersects = positions.some(
([_id, p]) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
const movingMachine = Array.from(highlightGroups["move"] || [])[0];
const startingPos = positions.find(([_id, p]) => _id === movingMachine);
if (startingPos) {
const isStartingPos =
snapped.x === startingPos[1].position[0] &&
snapped.z === startingPos[1].position[1];
// If Intersect any other machine and not the one being moved
if (!isStartingPos && intersects) {
return;
}
} else {
if (intersects) {
return;
}
}
return snapped;
};
const onAddClick = (event: MouseEvent) => {
setPositionMode("grid");
ctx.setWorldMode("create");
setWorldMode("create");
renderLoop.requestRender();
};
const onMouseMove = (event: MouseEvent) => {
if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return;
if (!(worldMode() === "create" || worldMode() === "move")) return;
if (!actionBase) return;
const actionRepr =
ctx.worldMode() === "create" ? actionBase : actionMachine;
if (!actionRepr) return;
console.log("Mouse move in create/move mode");
actionRepr.visible = true;
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
// );
actionBase.visible = true;
(actionBase.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
@@ -723,45 +611,41 @@ export function CubeScene(props: {
if (intersects.length > 0) {
const point = intersects[0].point;
const snapped = snapToGrid(point);
if (!snapped) return;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
0,
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
// Skip snapping if there's already a cube at this position
if (props.sceneStore()) {
const positions = Object.values(props.sceneStore());
const intersects = positions.some(
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
if (intersects) {
return;
}
}
if (
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
Math.abs(actionRepr.position.z - snapped.z) > 0.01
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
Math.abs(actionBase.position.z - snapped.z) > 0.01
) {
// Only request render if the position actually changed
actionRepr.position.set(snapped.x, 0, snapped.z);
actionBase.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
renderLoop.requestRender();
}
}
};
const handleMenuSelect = (mode: "move") => {
ctx.setWorldMode(mode);
setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) });
// Find the position of the first selected machine
// Set the actionMachine position to that
const firstId = menuIntersection()[0];
if (firstId) {
const machine = machineManager.machines.get(firstId);
if (machine && actionMachine) {
actionMachine.position.set(
machine.group.position.x,
0,
machine.group.position.z,
);
setCursorPosition([machine.group.position.x, machine.group.position.z]);
}
}
console.log("Menu selected, new World mode", worldMode());
};
createEffect(
on(ctx.worldMode, (mode) => {
console.log("World mode changed to", mode);
}),
);
const machinesQuery = useMachinesQuery(props.clanURI);
return (
@@ -780,10 +664,10 @@ export function CubeScene(props: {
<div
class={cx(
"cubes-scene-container",
ctx.worldMode() === "default" && "cursor-no-drop",
ctx.worldMode() === "select" && "cursor-pointer",
ctx.worldMode() === "service" && "cursor-pointer",
ctx.worldMode() === "create" && "cursor-cell",
worldMode() === "default" && "cursor-no-drop",
worldMode() === "select" && "cursor-pointer",
worldMode() === "service" && "cursor-pointer",
worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing",
)}
ref={(el) => (container = el)}
@@ -797,25 +681,24 @@ export function CubeScene(props: {
description="Select machine"
name="Select"
icon="Cursor"
onClick={() => ctx.setWorldMode("select")}
selected={ctx.worldMode() === "select"}
onClick={() => setWorldMode("select")}
selected={worldMode() === "select"}
/>
<ToolbarButton
description="Create new machine"
name="new-machine"
icon="NewMachine"
onClick={onAddClick}
selected={ctx.worldMode() === "create"}
selected={worldMode() === "create"}
/>
<Divider orientation="vertical" />
<ToolbarButton
description="Add new Service"
name="modules"
icon="Services"
selected={ctx.worldMode() === "service"}
selected={worldMode() === "service"}
onClick={() => {
ctx.navigateToRoot();
ctx.setWorldMode("service");
setWorldMode("service");
}}
/>
<ToolbarButton

View File

@@ -6,35 +6,3 @@ export const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>
},
{} as Pick<T, K>,
);
export const removeEmptyStrings = <T>(obj: T): T => {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === "string") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => removeEmptyStrings(item)) as T;
}
if (typeof obj === "object") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const key in obj) {
// eslint-disable-next-line no-prototype-builtins
if (obj.hasOwnProperty(key)) {
const value = obj[key];
if (value !== "") {
result[key] = removeEmptyStrings(value);
}
}
}
return result;
}
return obj;
};

View File

@@ -26,19 +26,13 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
const resultData: Partial<ResultDataMap> = {
list_machines: {
pandora: {
data: {
name: "pandora",
},
name: "pandora",
},
enceladus: {
data: {
name: "enceladus",
},
name: "enceladus",
},
dione: {
data: {
name: "dione",
},
name: "dione",
},
},
};

View File

@@ -41,7 +41,7 @@ export interface AddMachineProps {
export interface AddMachineStoreType {
general: GeneralForm;
deploy: {
targetHost?: string;
targetHost: string;
};
tags: {
tags: string[];
@@ -111,7 +111,10 @@ export const AddMachine = (props: AddMachineProps) => {
return defaultClass;
}
return defaultClass;
switch (currentStep.id) {
default:
return defaultClass;
}
};
return (

View File

@@ -22,7 +22,7 @@ export const StepProgress = (props: StepProgressProps) => {
when={store.error}
fallback={
<>
<Loader size="l" />
<Loader class="size-8" />
<Typography hierarchy="body" size="s" weight="medium" family="mono">
{store.general?.name} is being created
</Typography>

View File

@@ -1,12 +1,7 @@
import { BackButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
setValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
@@ -16,7 +11,6 @@ import { MachineTags } from "@/src/components/Form/MachineTags";
import { Button } from "@/src/components/Button/Button";
import { useApiClient } from "@/src/hooks/ApiClient";
import { useClanURI } from "@/src/hooks/clan";
import { removeEmptyStrings } from "@/src/util";
const TagsSchema = v.object({
tags: v.array(v.string()),
@@ -42,20 +36,16 @@ export const StepTags = (props: { onDone: () => void }) => {
...values,
}));
const machine = removeEmptyStrings({
...store.general,
...store.tags,
deploy: store.deploy,
});
console.log("machine", machine);
const call = apiClient.fetch("create_machine", {
opts: {
clan_dir: {
identifier: clanURI,
},
machine,
machine: {
...store.general,
...store.tags,
deploy: store.deploy,
},
},
});
@@ -88,12 +78,9 @@ export const StepTags = (props: { onDone: () => void }) => {
{...field}
required
orientation="horizontal"
defaultValue={field.value || []}
defaultValue={field.value}
defaultOptions={[]}
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
input={input}
/>
)}
</Field>

View File

@@ -75,7 +75,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "hidden",
prompt_type: "line",
display: {
helperText: null,
label: "(2) Password",
@@ -113,7 +113,7 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "hidden",
prompt_type: "line",
display: {
helperText: null,
label: "(5) Password",

View File

@@ -51,13 +51,12 @@ export interface InstallStoreType {
progress: ApiCall<"run_machine_flash">;
};
install: {
targetHost?: string;
targetHost: string;
port?: string;
password?: string;
machineName: string;
mainDisk?: string;
mainDisk: string;
// ...TODO Vars
progress: ApiCall<"run_machine_install" | "run_machine_update">;
progress: ApiCall<"run_machine_install">;
promptValues: PromptValues;
prepareStep: "disk" | "generators" | "install";
};
@@ -107,23 +106,22 @@ export const InstallModal = (props: InstallModalProps) => {
}
};
const onClose = async () => {
props.onClose?.();
};
return (
<StepperProvider stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Install machine"
onClose={onClose}
onClose={() => {
console.log("Install modal closed");
props.onClose?.();
}}
open={props.open}
// @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
<InstallStepper onDone={onClose} />
<InstallStepper onDone={() => props.onClose} />
</Modal>
</StepperProvider>
);

View File

@@ -1,198 +0,0 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
import { UpdateModal } from "./UpdateMachine";
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
get_generators: [
{
name: "funny.gritty",
prompts: [
{
name: "gritty.name",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(1) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(2) Password",
group: "Root",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(3) Gritty",
group: "Root",
required: true,
},
},
],
},
{
name: "funny.dodo",
prompts: [
{
name: "gritty.name",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(4) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(5) Password",
group: "Lonely",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(6) Batty",
group: "Root",
required: true,
},
},
],
},
],
run_generators: null,
run_machine_update: null,
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
const status = name === "run_machine_update" ? "error" : "success";
resolve({
op_key: "1",
status: status,
errors: [
{
message: "Mock error message",
description:
"This is a more detailed description of the mock error.",
},
],
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof UpdateModal> = {
title: "workflows/update",
component: UpdateModal,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div class="w-[600px]">
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof UpdateModal>;
export const Init: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
},
};
export const Address: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
initialStep: "update:address",
},
};
export const UpdateProgress: Story = {
description: "Welcome step for the update workflow",
args: {
open: true,
machineName: "Jon",
initialStep: "update:progress",
},
};

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