Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584f3247a | ||
|
|
d3534a2b72 | ||
|
|
83e51db2e7 | ||
|
|
4e4af8a52f | ||
|
|
d3e5e6edf1 | ||
|
|
a4277ad312 | ||
|
|
8877f2d451 | ||
|
|
9275b66bd9 | ||
|
|
6a964f37d5 | ||
|
|
73f2a4f56f | ||
|
|
85fb0187ee | ||
|
|
db9812a08b | ||
|
|
ca69530591 | ||
|
|
fc5b0e4113 | ||
|
|
278af5f0f4 | ||
|
|
e7baf25ff7 | ||
|
|
fada75144c | ||
|
|
803ef5476f | ||
|
|
016bd263d0 | ||
|
|
f9143f8a5d | ||
|
|
92eb27fcb1 | ||
|
|
0cc9b91ae8 | ||
|
|
2ed3608e34 | ||
|
|
a92a1a7dd1 | ||
|
|
9a903be6d4 | ||
|
|
adea270b27 | ||
|
|
765eb142a5 | ||
|
|
faa1405d6b | ||
|
|
0c93aab818 | ||
|
|
56923ae2c3 | ||
|
|
e2f64e1d40 | ||
|
|
c574b84278 | ||
|
|
640f15d55e | ||
|
|
789d326273 | ||
|
|
1763d85d91 | ||
|
|
082fa05083 | ||
|
|
9ed7190606 | ||
|
|
6c22539dd4 | ||
|
|
e6819ede61 | ||
|
|
186a760529 | ||
|
|
a84aee7b0c | ||
|
|
cab2fa44ba | ||
|
|
5962149e55 | ||
|
|
00f9d08a4b | ||
|
|
3d0c843308 | ||
|
|
847138472b | ||
|
|
c7786a59fd | ||
|
|
3b2d357f10 | ||
|
|
a83dbf604c | ||
|
|
f77456a123 | ||
|
|
6e4c3a638d | ||
|
|
3d2127ce1e | ||
|
|
a4a5916fa2 | ||
|
|
f6727055cd | ||
|
|
0517d87caa | ||
|
|
89e587592c | ||
|
|
439495d738 | ||
|
|
0b2fd681be | ||
|
|
41de615331 | ||
|
|
b7639b1d81 | ||
|
|
602879c9e4 | ||
|
|
53e16242b9 | ||
|
|
24c5146763 | ||
|
|
dca7aa0487 | ||
|
|
647bc4e4df | ||
|
|
1c80223fe3 | ||
|
|
7ac9b00398 | ||
|
|
d37c9e3b04 | ||
|
|
0fe9d0e157 | ||
|
|
5479c767c1 | ||
|
|
edc389ba4b | ||
|
|
4cb17d42e1 | ||
|
|
f26499edb8 | ||
|
|
2857cb7ed8 | ||
|
|
3168fecd52 | ||
|
|
24c20ff243 | ||
|
|
8ba8fda54b | ||
|
|
0992a47b00 | ||
|
|
d5b09f18ed | ||
|
|
fb2fe36c87 | ||
|
|
3db51887b1 | ||
|
|
24f3bcca57 | ||
|
|
85006c8103 | ||
|
|
db5571d623 | ||
|
|
d4bdaec586 | ||
|
|
cb9c8e5b5a | ||
|
|
0a1802c341 | ||
|
|
dfae1a4429 | ||
|
|
c1dc73a21b | ||
|
|
8145740cc1 | ||
|
|
b2a54f5b0d | ||
|
|
9c9adc6e16 | ||
|
|
f7cde8eb0f | ||
|
|
501d020562 | ||
|
|
a9bafd71e1 | ||
|
|
166e4b8081 | ||
|
|
c3eb40f17a | ||
|
|
7330285150 | ||
|
|
8cf8573c61 | ||
|
|
5bfa0d7a9d | ||
|
|
8ea2dd9b72 | ||
|
|
6efcade56a | ||
|
|
6d2372be56 | ||
|
|
626af4691b | ||
|
|
63697ac4b1 | ||
|
|
0ebb1f0c66 | ||
|
|
1dda60847e | ||
|
|
a7bce4cb19 | ||
|
|
a5474bc25f | ||
|
|
f634b8f1fb | ||
|
|
0ad40a0233 | ||
|
|
78abc36cd3 | ||
|
|
f5158b068f | ||
|
|
e6066a6cb1 | ||
|
|
fc8b66effa | ||
|
|
16b92963fd | ||
|
|
2ff3d871ac | ||
|
|
108936ef07 | ||
|
|
c45d4cfec9 | ||
|
|
64217e1281 | ||
|
|
d1421bb534 | ||
|
|
ac20514a8e | ||
|
|
79c4e73a15 | ||
|
|
61a647b436 | ||
|
|
c9a709783a | ||
|
|
c55b369899 | ||
|
|
084b8bacd3 | ||
|
|
47ad7d8a95 | ||
|
|
3798808013 | ||
|
|
43a39267f3 | ||
|
|
db94ea2d2e | ||
|
|
f0533f9bba | ||
|
|
360048fd04 | ||
|
|
8f8426de52 | ||
|
|
4bce390e64 | ||
|
|
2b7837e2b6 | ||
|
|
cbf9678534 | ||
|
|
b38b10c9a6 | ||
|
|
31cbb7dc00 | ||
|
|
0fa4377793 | ||
|
|
7b0d10e8c2 | ||
|
|
bb41adab4b | ||
|
|
648aa7dc59 | ||
|
|
3073969c92 | ||
|
|
2f1dc3a33d | ||
|
|
b707dcea2d | ||
|
|
4f0c8025b2 | ||
|
|
b91bee537a | ||
|
|
7207a3e8cd | ||
|
|
ac675a5af0 | ||
|
|
64caebde62 | ||
|
|
4934884e0c | ||
|
|
22cd9baee2 | ||
|
|
84232b5355 | ||
|
|
5bc7c255c1 | ||
|
|
d11d83f699 | ||
|
|
2ef1b2a8fa | ||
|
|
f7414d7e6e | ||
|
|
ab384150b2 | ||
|
|
0b6939ffee | ||
|
|
bc6a1a9d17 | ||
|
|
7055461cf0 | ||
|
|
a9564df6a9 | ||
|
|
e2dfc74d02 | ||
|
|
326cb60aea | ||
|
|
68b264970a | ||
|
|
1fa4ef82e9 | ||
|
|
ec70de406b |
2
.github/workflows/repo-sync.yml
vendored
2
.github/workflows/repo-sync.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
if: github.repository_owner == 'clan-lol'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@v2
|
||||
|
||||
@@ -302,7 +302,8 @@
|
||||
"test-install-machine-without-system",
|
||||
"-i", ssh_conn.ssh_key,
|
||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
|
||||
"--yes"
|
||||
]
|
||||
|
||||
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||
@@ -326,7 +327,9 @@
|
||||
"test-install-machine-without-system",
|
||||
"-i", ssh_conn.ssh_key,
|
||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||
"--target-host",
|
||||
f"nonrootuser@localhost:{ssh_conn.host_port}",
|
||||
"--yes"
|
||||
]
|
||||
|
||||
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||
|
||||
68
clanServices/coredns/README.md
Normal file
68
clanServices/coredns/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
This module enables hosting clan-internal services easily, which can be resolved
|
||||
inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`)
|
||||
and exposing endpoints from a machine to others, which will be
|
||||
accessible under `http://<service>.clan` in your browser.
|
||||
|
||||
The service consists of two roles:
|
||||
|
||||
- A `server` role: This is the DNS-server that will be queried when trying to
|
||||
resolve clan-internal services. It defines the top-level domain.
|
||||
- A `default` role: This does two things. First, it sets up the nameservers so
|
||||
thatclan-internal queries are resolved via the `server` machine, while
|
||||
external queries are resolved as normal via DHCP. Second, it allows exposing
|
||||
services (see example below).
|
||||
|
||||
## Example Usage
|
||||
|
||||
Here the machine `dnsserver` is designated as internal DNS-server for the TLD
|
||||
`.foo`. `server01` will host an application that shall be reachable at
|
||||
`http://one.foo` and `server02` is going to be reachable at `http://two.foo`.
|
||||
`client` is any other machine that is part of the clan but does not host any
|
||||
services.
|
||||
|
||||
When `client` tries to resolve `http://one.foo`, the DNS query will be
|
||||
routed to `dnsserver`, which will answer with `192.168.1.3`. If it tries to
|
||||
resolve some external domain (e.g. `https://clan.lol`), the query will not be
|
||||
routed to `dnsserver` but resolved as before, via the nameservers advertised by
|
||||
DHCP.
|
||||
|
||||
```nix
|
||||
inventory = {
|
||||
|
||||
machines = {
|
||||
dnsserver = { }; # 192.168.1.2
|
||||
server01 = { }; # 192.168.1.3
|
||||
server02 = { }; # 192.168.1.4
|
||||
client = { }; # 192.168.1.5
|
||||
};
|
||||
|
||||
instances = {
|
||||
coredns = {
|
||||
|
||||
module.name = "@clan/coredns";
|
||||
module.input = "self";
|
||||
|
||||
# Add the default role to all machines, including `client`
|
||||
roles.default.tags.all = { };
|
||||
|
||||
# DNS server
|
||||
roles.server.machines."dnsserver".settings = {
|
||||
ip = "192.168.1.2";
|
||||
tld = "foo";
|
||||
};
|
||||
|
||||
# First service
|
||||
roles.default.machines."server01".settings = {
|
||||
ip = "192.168.1.3";
|
||||
services = [ "one" ];
|
||||
};
|
||||
|
||||
# Second service
|
||||
roles.default.machines."server02".settings = {
|
||||
ip = "192.168.1.4";
|
||||
services = [ "two" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
157
clanServices/coredns/default.nix
Normal file
157
clanServices/coredns/default.nix
Normal file
@@ -0,0 +1,157 @@
|
||||
{ ... }:
|
||||
{
|
||||
_class = "clan.service";
|
||||
manifest.name = "coredns";
|
||||
manifest.description = "Clan-internal DNS and service exposure";
|
||||
manifest.categories = [ "Network" ];
|
||||
manifest.readme = builtins.readFile ./README.md;
|
||||
|
||||
roles.server = {
|
||||
|
||||
interface =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.tld = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "clan";
|
||||
description = ''
|
||||
Top-level domain for this instance. All services below this will be
|
||||
resolved internally.
|
||||
'';
|
||||
};
|
||||
|
||||
options.ip = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
# TODO: Set a default
|
||||
description = "IP for the DNS to listen on";
|
||||
};
|
||||
};
|
||||
|
||||
perInstance =
|
||||
{
|
||||
roles,
|
||||
settings,
|
||||
...
|
||||
}:
|
||||
{
|
||||
nixosModule =
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 53 ];
|
||||
networking.firewall.allowedUDPPorts = [ 53 ];
|
||||
|
||||
services.coredns =
|
||||
let
|
||||
|
||||
# Get all service entries for one host
|
||||
hostServiceEntries =
|
||||
host:
|
||||
lib.strings.concatStringsSep "\n" (
|
||||
map (
|
||||
service: "${service} IN A ${roles.default.machines.${host}.settings.ip} ; ${host}"
|
||||
) roles.default.machines.${host}.settings.services
|
||||
);
|
||||
|
||||
zonefile = pkgs.writeTextFile {
|
||||
name = "db.${settings.tld}";
|
||||
text = ''
|
||||
$TTL 3600
|
||||
@ IN SOA ns.${settings.tld}. admin.${settings.tld}. 1 7200 3600 1209600 3600
|
||||
IN NS ns.${settings.tld}.
|
||||
ns IN A ${settings.ip} ; DNS server
|
||||
|
||||
''
|
||||
+ (lib.strings.concatStringsSep "\n" (
|
||||
map (host: hostServiceEntries host) (lib.attrNames roles.default.machines)
|
||||
));
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
enable = true;
|
||||
config = ''
|
||||
. {
|
||||
forward . 1.1.1.1
|
||||
cache 30
|
||||
}
|
||||
|
||||
${settings.tld} {
|
||||
file ${zonefile}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
roles.default = {
|
||||
interface =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.services = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Service endpoints this host exposes (without TLD). Each entry will
|
||||
be resolved to <entry>.<tld> using the configured top-level domain.
|
||||
'';
|
||||
};
|
||||
|
||||
options.ip = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
# TODO: Set a default
|
||||
description = "IP on which the services will listen";
|
||||
};
|
||||
};
|
||||
|
||||
perInstance =
|
||||
{ roles, ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{ lib, ... }:
|
||||
{
|
||||
|
||||
networking.nameservers = map (m: "127.0.0.1:5353#${roles.server.machines.${m}.settings.tld}") (
|
||||
lib.attrNames roles.server.machines
|
||||
);
|
||||
|
||||
services.resolved.domains = map (m: "~${roles.server.machines.${m}.settings.tld}") (
|
||||
lib.attrNames roles.server.machines
|
||||
);
|
||||
|
||||
services.unbound = {
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
port = 5353;
|
||||
verbosity = 2;
|
||||
interface = [ "127.0.0.1" ];
|
||||
access-control = [ "127.0.0.0/8 allow" ];
|
||||
do-not-query-localhost = "no";
|
||||
domain-insecure = map (m: "${roles.server.machines.${m}.settings.tld}.") (
|
||||
lib.attrNames roles.server.machines
|
||||
);
|
||||
};
|
||||
|
||||
# Default: forward everything else to DHCP-provided resolvers
|
||||
forward-zone = [
|
||||
{
|
||||
name = ".";
|
||||
forward-addr = "127.0.0.53@53"; # Forward to systemd-resolved
|
||||
}
|
||||
];
|
||||
stub-zone = map (m: {
|
||||
name = "${roles.server.machines.${m}.settings.tld}.";
|
||||
stub-addr = "${roles.server.machines.${m}.settings.ip}";
|
||||
}) (lib.attrNames roles.server.machines);
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,14 +3,16 @@ let
|
||||
module = lib.modules.importApply ./default.nix { };
|
||||
in
|
||||
{
|
||||
clan.modules.state-version = module;
|
||||
clan.modules = {
|
||||
coredns = module;
|
||||
};
|
||||
perSystem =
|
||||
{ ... }:
|
||||
{
|
||||
clan.nixosTests.state-version = {
|
||||
clan.nixosTests.coredns = {
|
||||
imports = [ ./tests/vm/default.nix ];
|
||||
|
||||
clan.modules."@clan/state-version" = module;
|
||||
clan.modules."@clan/coredns" = module;
|
||||
};
|
||||
};
|
||||
}
|
||||
113
clanServices/coredns/tests/vm/default.nix
Normal file
113
clanServices/coredns/tests/vm/default.nix
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
...
|
||||
}:
|
||||
{
|
||||
name = "coredns";
|
||||
|
||||
clan = {
|
||||
directory = ./.;
|
||||
test.useContainers = true;
|
||||
inventory = {
|
||||
|
||||
machines = {
|
||||
dns = { }; # 192.168.1.2
|
||||
server01 = { }; # 192.168.1.3
|
||||
server02 = { }; # 192.168.1.4
|
||||
client = { }; # 192.168.1.1
|
||||
};
|
||||
|
||||
instances = {
|
||||
coredns = {
|
||||
|
||||
module.name = "@clan/coredns";
|
||||
module.input = "self";
|
||||
|
||||
roles.default.tags.all = { };
|
||||
|
||||
# First service
|
||||
roles.default.machines."server01".settings = {
|
||||
ip = "192.168.1.3";
|
||||
services = [ "one" ];
|
||||
};
|
||||
|
||||
# Second service
|
||||
roles.default.machines."server02".settings = {
|
||||
ip = "192.168.1.4";
|
||||
services = [ "two" ];
|
||||
};
|
||||
|
||||
# DNS server
|
||||
roles.server.machines."dns".settings = {
|
||||
ip = "192.168.1.2";
|
||||
tld = "foo";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nodes = {
|
||||
dns =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = [ pkgs.net-tools ];
|
||||
};
|
||||
|
||||
client =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = [ pkgs.net-tools ];
|
||||
};
|
||||
|
||||
server01 = {
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."one.foo" = {
|
||||
locations."/" = {
|
||||
return = "200 'test server response one'";
|
||||
extraConfig = "add_header Content-Type text/plain;";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
server02 = {
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."two.foo" = {
|
||||
locations."/" = {
|
||||
return = "200 'test server response two'";
|
||||
extraConfig = "add_header Content-Type text/plain;";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
start_all()
|
||||
|
||||
machines = [server01, server02, dns, client]
|
||||
|
||||
for m in machines:
|
||||
m.systemctl("start network-online.target")
|
||||
|
||||
for m in machines:
|
||||
m.wait_for_unit("network-online.target")
|
||||
|
||||
# import time
|
||||
# time.sleep(2333333)
|
||||
|
||||
# This should work, but is borken in tests i think? Instead we dig directly
|
||||
|
||||
# client.succeed("curl -k -v http://one.foo")
|
||||
# client.succeed("curl -k -v http://two.foo")
|
||||
|
||||
answer = client.succeed("dig @192.168.1.2 one.foo")
|
||||
assert "192.168.1.3" in answer, "IP not found"
|
||||
|
||||
answer = client.succeed("dig @192.168.1.2 two.foo")
|
||||
assert "192.168.1.4" in answer, "IP not found"
|
||||
|
||||
'';
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
This service generates the `system.stateVersion` of the nixos installation
|
||||
automatically.
|
||||
|
||||
Possible values:
|
||||
[system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
|
||||
|
||||
## Usage
|
||||
|
||||
The following configuration will set `stateVersion` for all machines:
|
||||
|
||||
```
|
||||
inventory.instances = {
|
||||
state-version = {
|
||||
module = {
|
||||
name = "state-version";
|
||||
input = "clan";
|
||||
};
|
||||
roles.default.tags.all = { };
|
||||
};
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
If you are already setting `system.stateVersion`, either let the automatic
|
||||
generation happen, or trigger the generation manually for the machine. The
|
||||
service will take the specified version, if one is already supplied through the
|
||||
config.
|
||||
|
||||
To manually generate the version for a specified machine run:
|
||||
|
||||
```
|
||||
clan vars generate [MACHINE]
|
||||
```
|
||||
|
||||
If the setting was already set, you can then remove `system.stateVersion` from
|
||||
your machine configuration. For new machines, just import the service as shown
|
||||
above.
|
||||
@@ -1,50 +0,0 @@
|
||||
{ ... }:
|
||||
{
|
||||
_class = "clan.service";
|
||||
manifest.name = "clan-core/state-version";
|
||||
manifest.description = "Automatically generate the state version of the nixos installation.";
|
||||
manifest.categories = [ "System" ];
|
||||
manifest.readme = builtins.readFile ./README.md;
|
||||
|
||||
roles.default = {
|
||||
|
||||
perInstance =
|
||||
{ ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
var = config.clan.core.vars.generators.state-version.files.version or { };
|
||||
in
|
||||
{
|
||||
|
||||
warnings = [
|
||||
''
|
||||
The clan.state-version service is deprecated and will be
|
||||
removed on 2025-07-15 in favor of a nix option.
|
||||
|
||||
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
|
||||
''
|
||||
];
|
||||
|
||||
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
||||
|
||||
clan.core.vars.generators.state-version = {
|
||||
files.version = {
|
||||
secret = false;
|
||||
value = lib.mkDefault config.system.nixos.release;
|
||||
};
|
||||
runtimeInputs = [ ];
|
||||
script = ''
|
||||
echo -n ${config.system.stateVersion} > "$out"/version
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
name = "service-state-version";
|
||||
|
||||
clan = {
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
machines.server = { };
|
||||
instances.default = {
|
||||
module.name = "@clan/state-version";
|
||||
module.input = "self";
|
||||
roles.default.machines."server" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nodes.server = { };
|
||||
|
||||
testScript = lib.mkDefault ''
|
||||
start_all()
|
||||
'';
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
25.11
|
||||
@@ -12,6 +12,11 @@ import ipaddress
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Constants for argument count validation
|
||||
MIN_ARGS_BASE = 4
|
||||
MIN_ARGS_CONTROLLER = 5
|
||||
MIN_ARGS_PEER = 5
|
||||
|
||||
|
||||
def hash_string(s: str) -> str:
|
||||
"""Generate SHA256 hash of string."""
|
||||
@@ -39,8 +44,7 @@ def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
|
||||
prefix = f"fd{prefix_bits:08x}"
|
||||
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
|
||||
|
||||
network = ipaddress.IPv6Network(prefix_formatted)
|
||||
return network
|
||||
return ipaddress.IPv6Network(prefix_formatted)
|
||||
|
||||
|
||||
def generate_controller_subnet(
|
||||
@@ -60,9 +64,7 @@ def generate_controller_subnet(
|
||||
# The controller subnet is at base_prefix:controller_id::/56
|
||||
base_int = int(base_network.network_address)
|
||||
controller_subnet_int = base_int | (controller_id << (128 - 56))
|
||||
controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
|
||||
|
||||
return controller_subnet
|
||||
return ipaddress.IPv6Network((controller_subnet_int, 56))
|
||||
|
||||
|
||||
def generate_peer_suffix(peer_name: str) -> str:
|
||||
@@ -76,12 +78,11 @@ def generate_peer_suffix(peer_name: str) -> str:
|
||||
suffix_bits = h[:16]
|
||||
|
||||
# Format as IPv6 suffix without leading colon
|
||||
suffix = f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
|
||||
return suffix
|
||||
return f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 4:
|
||||
if len(sys.argv) < MIN_ARGS_BASE:
|
||||
print(
|
||||
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>",
|
||||
)
|
||||
@@ -95,7 +96,7 @@ def main() -> None:
|
||||
base_network = generate_ula_prefix(instance_name)
|
||||
|
||||
if node_type == "controller":
|
||||
if len(sys.argv) < 5:
|
||||
if len(sys.argv) < MIN_ARGS_CONTROLLER:
|
||||
print("Controller name required")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -111,7 +112,7 @@ def main() -> None:
|
||||
(output_dir / "prefix").write_text(prefix_str)
|
||||
|
||||
elif node_type == "peer":
|
||||
if len(sys.argv) < 5:
|
||||
if len(sys.argv) < MIN_ARGS_PEER:
|
||||
print("Peer name required")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
12
devFlake/flake.lock
generated
12
devFlake/flake.lock
generated
@@ -3,10 +3,10 @@
|
||||
"clan-core-for-checks": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1756133826,
|
||||
"narHash": "sha256-In3u7UVSjPzX9u4Af9K/jVy4MMAZBzxByMe4GREpHBo=",
|
||||
"lastModified": 1756166884,
|
||||
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
|
||||
"ref": "main",
|
||||
"rev": "c4da43da0f583bd3cbcfd1f3acf74f9dc51b8fdd",
|
||||
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://git.clan.lol/clan/clan-core"
|
||||
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1756104823,
|
||||
"narHash": "sha256-wRzHREXDOrbCjy+sqo4t3JoInbB2PuhXIUa8NWdh9tk=",
|
||||
"lastModified": 1756400612,
|
||||
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d7967bed5381e65208f4fb8d5502e3c36bb94759",
|
||||
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
||||
mirrorBoot = idx: {
|
||||
# suffix is to prevent disk name collisions
|
||||
name = idx + suffix;
|
||||
name = idx;
|
||||
type = "disk";
|
||||
device = "/dev/disk/by-id/${idx}";
|
||||
content = {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
||||
mirrorBoot = idx: {
|
||||
# suffix is to prevent disk name collisions
|
||||
name = idx + suffix;
|
||||
name = idx;
|
||||
type = "disk";
|
||||
device = "/dev/disk/by-id/${idx}";
|
||||
content = {
|
||||
|
||||
@@ -2,7 +2,7 @@ site_name: Clan Documentation
|
||||
site_url: https://docs.clan.lol
|
||||
repo_url: https://git.clan.lol/clan/clan-core/
|
||||
repo_name: "_>"
|
||||
edit_uri: _edit/main/docs/docs/
|
||||
edit_uri: _edit/main/docs/site/
|
||||
|
||||
validation:
|
||||
omitted_files: warn
|
||||
@@ -94,6 +94,7 @@ nav:
|
||||
- reference/clanServices/index.md
|
||||
- reference/clanServices/admin.md
|
||||
- reference/clanServices/borgbackup.md
|
||||
- reference/clanServices/coredns.md
|
||||
- reference/clanServices/data-mesher.md
|
||||
- reference/clanServices/dyndns.md
|
||||
- reference/clanServices/emergency-access.md
|
||||
@@ -106,7 +107,6 @@ nav:
|
||||
- reference/clanServices/monitoring.md
|
||||
- reference/clanServices/packages.md
|
||||
- reference/clanServices/sshd.md
|
||||
- reference/clanServices/state-version.md
|
||||
- reference/clanServices/syncthing.md
|
||||
- reference/clanServices/trusted-nix-caches.md
|
||||
- reference/clanServices/users.md
|
||||
@@ -173,6 +173,7 @@ theme:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
- content.action.edit
|
||||
icon:
|
||||
repo: fontawesome/brands/git
|
||||
custom_dir: overrides
|
||||
|
||||
@@ -48,7 +48,7 @@ CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
|
||||
|
||||
CLAN_MODULES_VIA_SERVICE = os.environ.get("CLAN_MODULES_VIA_SERVICE")
|
||||
|
||||
OUT = os.environ.get("out")
|
||||
OUT = os.environ.get("out") # noqa: SIM112
|
||||
|
||||
|
||||
def sanitize(text: str) -> str:
|
||||
@@ -551,8 +551,7 @@ def options_docs_from_tree(
|
||||
|
||||
return output
|
||||
|
||||
md = render_tree(root)
|
||||
return md
|
||||
return render_tree(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
# Using `clanServices`
|
||||
# Using the Inventory
|
||||
|
||||
Clan's `clanServices` system is a composable way to define and deploy services across machines.
|
||||
Clan's inventory system is a composable way to define and deploy services across
|
||||
machines.
|
||||
|
||||
This guide shows how to **instantiate** a `clanService`, explains how service definitions are structured in your inventory, and how to pick or create services from modules exposed by flakes.
|
||||
This guide shows how to **instantiate** a `clanService`, explains how service
|
||||
definitions are structured in your inventory, and how to pick or create services
|
||||
from modules exposed by flakes.
|
||||
|
||||
The term **Multi-host-modules** was introduced previously in the [nixus repository](https://github.com/infinisil/nixus) and represents a similar concept.
|
||||
The term **Multi-host-modules** was introduced previously in the [nixus
|
||||
repository](https://github.com/infinisil/nixus) and represents a similar
|
||||
concept.
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## Overview
|
||||
|
||||
Services are used in `inventory.instances`, and then they attach to *roles* and *machines* — meaning you decide which machines run which part of the service.
|
||||
Services are used in `inventory.instances`, and assigned to *roles* and
|
||||
*machines* -- meaning you decide which machines run which part of the service.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -18,116 +24,135 @@ For example:
|
||||
inventory.instances = {
|
||||
borgbackup = {
|
||||
roles.client.machines."laptop" = {};
|
||||
roles.client.machines."server1" = {};
|
||||
roles.client.machines."workstation" = {};
|
||||
|
||||
roles.server.machines."backup-box" = {};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This says: “Run borgbackup as a *client* on my *laptop* and *server1*, and as a *server* on *backup-box*.”
|
||||
This says: "Run borgbackup as a *client* on my *laptop* and *workstation*, and
|
||||
as a *server* on *backup-box*". `client` and `server` are roles defined by the
|
||||
`borgbackup` service.
|
||||
|
||||
## Module source specification
|
||||
|
||||
Each instance includes a reference to a **module specification** — this is how Clan knows which service module to use and where it came from.
|
||||
Usually one would just use `imports` but we needd to make the `module source` configurable via Python API.
|
||||
By default it is not required to specify the `module`, in which case it defaults to the preprovided services of clan-core.
|
||||
Each instance includes a reference to a **module specification** -- this is how
|
||||
Clan knows which service module to use and where it came from.
|
||||
|
||||
---
|
||||
|
||||
## Override Example
|
||||
It is not required to specify the `module.input` parameter, in which case it
|
||||
defaults to the pre-provided services of clan-core. In a similar fashion, the
|
||||
`module.name` parameter can also be omitted, it will default to the name of the
|
||||
instance.
|
||||
|
||||
Example of instantiating a `borgbackup` service using `clan-core`:
|
||||
|
||||
```nix
|
||||
inventory.instances = {
|
||||
# Instance Name: Different name for this 'borgbackup' instance
|
||||
borgbackup = {
|
||||
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
|
||||
module = {
|
||||
name = "borgbackup"; # <-- Name of the module (optional)
|
||||
input = "clan-core"; # <-- The flake input where the service is defined (optional)
|
||||
};
|
||||
|
||||
borgbackup = { # <- Instance name
|
||||
|
||||
# This can be partially/fully specified,
|
||||
# - If the instance name is not the name of the module
|
||||
# - If the input is not clan-core
|
||||
# module = {
|
||||
# name = "borgbackup"; # Name of the module (optional)
|
||||
# input = "clan-core"; # The flake input where the service is defined (optional)
|
||||
# };
|
||||
|
||||
# Participation of the machines is defined via roles
|
||||
# Right side needs to be an attribute set. Its purpose will become clear later
|
||||
roles.client.machines."machine-a" = {};
|
||||
roles.server.machines."backup-host" = {};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
If you used `clan-core` as an input attribute for your flake:
|
||||
## Module Settings
|
||||
|
||||
Each role might expose configurable options. See clan's [clanServices
|
||||
reference](../reference/clanServices/index.md) for all available options.
|
||||
|
||||
Settings can be set in per-machine or per-role. The latter is applied to all
|
||||
machines that are assigned to that role.
|
||||
|
||||
|
||||
```nix
|
||||
# ↓ module.input = "clan-core"
|
||||
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||
```
|
||||
|
||||
## Simplified Example
|
||||
|
||||
If only one instance is needed for a service and the service is a clan core service, the `module` definition can be omitted.
|
||||
|
||||
```nix
|
||||
# Simplified way of specifying a single instance
|
||||
inventory.instances = {
|
||||
# instance name is `borgbackup` -> clan core module `borgbackup` will be loaded.
|
||||
borgbackup = {
|
||||
# Participation of the machines is defined via roles
|
||||
# Right side needs to be an attribute set. Its purpose will become clear later
|
||||
roles.client.machines."machine-a" = {};
|
||||
roles.server.machines."backup-host" = {};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Example
|
||||
|
||||
Each role might expose configurable options
|
||||
|
||||
See clan's [clanServices reference](../reference/clanServices/index.md) for available options
|
||||
|
||||
```nix
|
||||
inventory.instances = {
|
||||
borgbackup-example = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
# Settings for 'machine-a'
|
||||
roles.client.machines."machine-a" = {
|
||||
# 'client' -Settings of 'machine-a'
|
||||
settings = {
|
||||
backupFolders = [
|
||||
/home
|
||||
/var
|
||||
];
|
||||
};
|
||||
# ---------------------------
|
||||
};
|
||||
roles.server.machines."backup-host" = {};
|
||||
|
||||
# Settings for all machines of the role "server"
|
||||
roles.server.settings = {
|
||||
directory = "/var/lib/borgbackup";
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Tags
|
||||
|
||||
Multiple members can be defined using tags as follows
|
||||
Tags can be used to assign multiple machines to a role at once. It can be thought of as a grouping mechanism.
|
||||
|
||||
For example using the `all` tag for services that you want to be configured on all
|
||||
your machines is a common pattern.
|
||||
|
||||
The following example could be used to backup all your machines to a common
|
||||
backup server
|
||||
|
||||
```nix
|
||||
inventory.instances = {
|
||||
borgbackup-example = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
#
|
||||
# The 'all' -tag targets all machines
|
||||
roles.client.tags."all" = {};
|
||||
# ---------------------------
|
||||
borgbackup = {
|
||||
# "All" machines are assigned to the borgbackup 'client' role
|
||||
roles.client.tags = [ "all" ];
|
||||
|
||||
# But only one specific machine (backup-host) is assigned to the 'server' role
|
||||
roles.server.machines."backup-host" = {};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Sharing additional Nix configuration
|
||||
|
||||
Sometimes you need to add custom NixOS configuration alongside your clan
|
||||
services. The `extraModules` option allows you to include additional NixOS
|
||||
configuration that is applied for every machine assigned to that role.
|
||||
|
||||
There are multiple valid syntaxes for specifying modules:
|
||||
|
||||
```nix
|
||||
inventory.instances = {
|
||||
borgbackup = {
|
||||
roles.client = {
|
||||
# Direct module reference
|
||||
extraModules = [ ../nixosModules/borgbackup.nix ];
|
||||
|
||||
# Or using self (needs to be json serializable)
|
||||
# See next example, for a workaround.
|
||||
extraModules = [ self.nixosModules.borgbackup ];
|
||||
|
||||
# Or inline module definition, (needs to be json compatible)
|
||||
extraModules = [
|
||||
{
|
||||
# Your module configuration here
|
||||
# ...
|
||||
#
|
||||
# If the module needs to contain non-serializable expressions:
|
||||
imports = [ ./path/to/non-serializable.nix ];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Picking a clanService
|
||||
|
||||
You can use services exposed by Clan's core module library, `clan-core`.
|
||||
@@ -142,18 +167,19 @@ You can also author your own `clanService` modules.
|
||||
|
||||
You might expose your service module from your flake — this makes it easy for other people to also use your module in their clan.
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## 💡 Tips for Working with clanServices
|
||||
|
||||
* You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
|
||||
* Each service instance is isolated by its key in `inventory.instances`, allowing you to deploy multiple versions or roles of the same service type.
|
||||
* Roles can target different machines or be scoped dynamically.
|
||||
- You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
|
||||
- Each service instance is isolated by its key in `inventory.instances`, allowing to deploy multiple versions or roles of the same service type.
|
||||
- Roles can target different machines or be scoped dynamically.
|
||||
|
||||
---
|
||||
______________________________________________________________________
|
||||
|
||||
## What's Next?
|
||||
|
||||
* [Author your own clanService →](../guides/services/community.md)
|
||||
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
|
||||
- [Author your own clanService →](../guides/services/community.md)
|
||||
- [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
|
||||
|
||||
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
|
||||
# Update Your Machines
|
||||
# Update Machines
|
||||
|
||||
Clan CLI enables you to remotely update your machines over SSH. This requires setting up a target address for each target machine.
|
||||
The Clan command line interface enables you to update machines remotely over SSH.
|
||||
In this guide we will teach you how to set a `targetHost` in Nix,
|
||||
and how to define a remote builder for your machine closures.
|
||||
|
||||
### Setting `targetHost`
|
||||
|
||||
In your Nix files, set the `targetHost` to the reachable IP address of your new machine. This eliminates the need to specify `--target-host` with every command.
|
||||
## Setting `targetHost`
|
||||
|
||||
Set the machine’s `targetHost` to the reachable IP address of the new machine.
|
||||
This eliminates the need to specify `--target-host` in CLI commands.
|
||||
|
||||
```{.nix title="clan.nix" hl_lines="9"}
|
||||
{
|
||||
@@ -23,15 +26,42 @@ inventory.machines = {
|
||||
# [...]
|
||||
}
|
||||
```
|
||||
|
||||
The use of `root@` in the target address implies SSH access as the `root` user.
|
||||
Ensure that the root login is secured and only used when necessary.
|
||||
|
||||
## Multiple Target Hosts
|
||||
|
||||
### Setting a Build Host
|
||||
You can now experiment with a new interface that allows you to define multiple `targetHost` addresses for different VPNs. Learn more and try it out in our [networking guide](../networking.md).
|
||||
|
||||
If the machine does not have enough resources to run the NixOS evaluation or build itself,
|
||||
it is also possible to specify a build host instead.
|
||||
During an update, the cli will ssh into the build host and run `nixos-rebuild` from there.
|
||||
## Updating Machine Configurations
|
||||
|
||||
Execute the following command to update the specified machine:
|
||||
|
||||
```bash
|
||||
clan machines update jon
|
||||
```
|
||||
|
||||
All machines can be updated simultaneously by omitting the machine name:
|
||||
|
||||
```bash
|
||||
clan machines update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
The following options are only needed for special cases, such as limited resources, mixed environments, or private flakes.
|
||||
|
||||
### Setting `buildHost`
|
||||
|
||||
If the machine does not have enough resources to run the NixOS **evaluation** or **build** itself,
|
||||
it is also possible to specify a `buildHost` instead.
|
||||
During an update, clan will ssh into the `buildHost` and run `nixos-rebuild` from there.
|
||||
|
||||
!!! Note
|
||||
The `buildHost` option should be set directly within your machine’s Nix configuration, **not** under `inventory.machines`.
|
||||
|
||||
|
||||
```{.nix hl_lines="5" .no-copy}
|
||||
@@ -45,7 +75,11 @@ buildClan {
|
||||
};
|
||||
```
|
||||
|
||||
You can also override the build host via the command line:
|
||||
### Overriding configuration with CLI flags
|
||||
|
||||
`buildHost` / `targetHost`, and other network settings can be temporarily overridden for a single command:
|
||||
|
||||
For the full list of flags refer to the [Clan CLI](../../reference/cli/index.md)
|
||||
|
||||
```bash
|
||||
# Build on a remote host
|
||||
@@ -56,23 +90,9 @@ clan machines update jon --build-host local
|
||||
```
|
||||
|
||||
!!! Note
|
||||
Make sure that the CPU architecture is the same for the buildHost as for the targetHost.
|
||||
Example:
|
||||
If you want to deploy to a macOS machine, your architecture is an ARM64-Darwin, that means you need a second macOS machine to build it.
|
||||
Make sure the CPU architecture of the `buildHost` matches that of the `targetHost`
|
||||
|
||||
### Updating Machine Configurations
|
||||
|
||||
Execute the following command to update the specified machine:
|
||||
|
||||
```bash
|
||||
clan machines update jon
|
||||
```
|
||||
|
||||
You can also update all configured machines simultaneously by omitting the machine name:
|
||||
|
||||
```bash
|
||||
clan machines update
|
||||
```
|
||||
For example, if deploying to a macOS machine with an ARM64-Darwin architecture, you need a second macOS machine with the same architecture to build it.
|
||||
|
||||
|
||||
### Excluding a machine from `clan machine update`
|
||||
@@ -96,14 +116,15 @@ This is useful for machines that are not always online or are not part of the re
|
||||
### Uploading Flake Inputs
|
||||
|
||||
When updating remote machines, flake inputs are usually fetched by the build host.
|
||||
However, if your flake inputs require authentication (e.g., private repositories),
|
||||
you can use the `--upload-inputs` flag to upload all inputs from your local machine:
|
||||
However, if flake inputs require authentication (e.g., private repositories),
|
||||
|
||||
Use the `--upload-inputs` flag to upload all inputs from your local machine:
|
||||
|
||||
```bash
|
||||
clan machines update jon --upload-inputs
|
||||
```
|
||||
|
||||
This is particularly useful when:
|
||||
- Your flake references private Git repositories
|
||||
- Authentication credentials are only available on your local machine
|
||||
- The flake references private Git repositories
|
||||
- Authentication credentials are only available on local machine
|
||||
- The build host doesn't have access to certain network resources
|
||||
|
||||
@@ -254,7 +254,7 @@ The following table shows the migration status of each deprecated clanModule:
|
||||
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
|
||||
| `deltachat` | ❌ Removed | |
|
||||
| `disk-id` | ❌ Removed | |
|
||||
| `dyndns` | [Being Migrated](https://git.clan.lol/clan/clan-core/pulls/4390) | |
|
||||
| `dyndns` | ✅ [Migrated](../../reference/clanServices/dyndns.md) | |
|
||||
| `ergochat` | ❌ Removed | |
|
||||
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
|
||||
| `golem-provider` | ❌ Removed | |
|
||||
@@ -263,18 +263,18 @@ The following table shows the migration status of each deprecated clanModule:
|
||||
| `iwd` | ❌ Removed | Use [wifi service](../../reference/clanServices/wifi.md) instead |
|
||||
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
|
||||
| `localsend` | ❌ Removed | |
|
||||
| `machine-id` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
|
||||
| `machine-id` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
|
||||
| `matrix-synapse` | ✅ [Migrated](../../reference/clanServices/matrix-synapse.md) | |
|
||||
| `moonlight` | ❌ Removed | |
|
||||
| `mumble` | ❌ Removed | |
|
||||
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
|
||||
| `nginx` | ❌ Removed | |
|
||||
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
|
||||
| `postgresql` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
|
||||
| `postgresql` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
|
||||
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | See [migration guide](../../reference/clanServices/users.md#migration-from-root-password-module) |
|
||||
| `single-disk` | ❌ Removed | |
|
||||
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
|
||||
| `state-version` | ✅ [Migrated](../../reference/clanServices/state-version.md) | |
|
||||
| `state-version` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
|
||||
| `static-hosts` | ❌ Removed | |
|
||||
| `sunshine` | ❌ Removed | |
|
||||
| `syncthing-static-peers` | ❌ Removed | |
|
||||
|
||||
@@ -255,11 +255,50 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}:
|
||||
})
|
||||
```
|
||||
|
||||
The benefit of this approach is that downstream users can override the value of `myClan` by using `mkForce` or other priority modifiers.
|
||||
The benefit of this approach is that downstream users can override the value of
|
||||
`myClan` by using `mkForce` or other priority modifiers.
|
||||
|
||||
## Example: A machine-type service
|
||||
|
||||
Users often have different types of machines. These could be any classification
|
||||
you like, for example "servers" and "desktops". Having such distictions, allows
|
||||
reusing parts of your configuration that should be appplied to a class of
|
||||
machines. Since this is such a common pattern, here is how to write such a
|
||||
service.
|
||||
|
||||
For this example the we have to roles: `server` and `desktop`. Additionally, we
|
||||
can use the `perMachine` section to add configuration to all machines regardless
|
||||
of their type.
|
||||
|
||||
```nix title="machine-type.nix"
|
||||
{
|
||||
_class = "clan.service";
|
||||
manifest.name = "machine-type";
|
||||
|
||||
roles.server.perInstance.nixosModule = ./server.nix;
|
||||
roles.desktop.perInstance.nixosModule = ./desktop.nix;
|
||||
|
||||
perMachine.nixosModule = {
|
||||
# Configuration for all machines (any type)
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
In the inventory we the assign machines to a type, e.g. by using tags
|
||||
|
||||
```nix title="flake.nix"
|
||||
instnaces.machine-type = {
|
||||
module.input = "self";
|
||||
module.name = "@pinpox/machine-type";
|
||||
roles.desktop.tags.desktop = { };
|
||||
roles.server.tags.server = { };
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Further
|
||||
## Further Reading
|
||||
|
||||
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
|
||||
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -99,11 +99,11 @@
|
||||
},
|
||||
"nixos-facter-modules": {
|
||||
"locked": {
|
||||
"lastModified": 1756109073,
|
||||
"narHash": "sha256-5pjFEziluVwJ0Z50h9laKfWbDluXuA5ada05xb/QiV4=",
|
||||
"lastModified": 1756291602,
|
||||
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-facter-modules",
|
||||
"rev": "a1042c81126d9c9314c1eb1a7b89ab4d81b5dea7",
|
||||
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -324,14 +324,13 @@ class Machine:
|
||||
# Always run command with shell opts
|
||||
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
|
||||
|
||||
proc = subprocess.run(
|
||||
return subprocess.run(
|
||||
self.nsenter_command(command),
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return proc
|
||||
|
||||
def nested(
|
||||
self,
|
||||
|
||||
@@ -180,15 +180,15 @@ class CompositeLogger(AbstractLogger):
|
||||
stack.enter_context(logger.nested(message, attributes))
|
||||
yield
|
||||
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
for logger in self.logger_list:
|
||||
logger.info(*args, **kwargs)
|
||||
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
for logger in self.logger_list:
|
||||
logger.warning(*args, **kwargs)
|
||||
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
for logger in self.logger_list:
|
||||
logger.error(*args, **kwargs)
|
||||
sys.exit(1)
|
||||
@@ -245,13 +245,13 @@ class TerminalLogger(AbstractLogger):
|
||||
toc = time.time()
|
||||
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
|
||||
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def print_serial_logs(self, enable: bool) -> None:
|
||||
@@ -297,13 +297,13 @@ class XMLLogger(AbstractLogger):
|
||||
self.xml.characters(message)
|
||||
self.xml.endElement("line")
|
||||
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
|
||||
self.log(*args, **kwargs)
|
||||
|
||||
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
{
|
||||
imports = lib.optional (_class == "nixos") (
|
||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||
|
||||
# Enable automatic state-version generation.
|
||||
clan.core.settings.state-version.enable = true;
|
||||
|
||||
# Use systemd during boot as well except:
|
||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
||||
# - for containers we currently rely on the `stage-2` init script that sets up our /etc
|
||||
@@ -37,6 +41,7 @@
|
||||
};
|
||||
|
||||
config = lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||
|
||||
# This disables the HTML manual and `nixos-help` command but leaves
|
||||
# `man configuration.nix`
|
||||
documentation.doc.enable = lib.mkDefault false;
|
||||
|
||||
@@ -9,28 +9,11 @@
|
||||
|
||||
clan = {
|
||||
directory = ./.;
|
||||
|
||||
# Workaround until we can use nodes.server = { };
|
||||
modules."@clan/importer" = ../../../../clanServices/importer;
|
||||
|
||||
inventory = {
|
||||
machines.server = { };
|
||||
instances.importer = {
|
||||
module.name = "@clan/importer";
|
||||
module.input = "self";
|
||||
roles.default.tags.all = { };
|
||||
roles.default.extraModules = [
|
||||
{
|
||||
clan.core.settings.state-version.enable = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
machines.server = {
|
||||
clan.core.settings.state-version.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# TODO: Broken. Use instead of importer after fixing.
|
||||
# nodes.server = { };
|
||||
|
||||
# This is not an actual vm test, this is a workaround to
|
||||
# generate the needed vars for the eval test.
|
||||
testScript = "";
|
||||
|
||||
@@ -16,6 +16,10 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
|
||||
# Constants
|
||||
NODE_ID_LENGTH = 10
|
||||
NETWORK_ID_LENGTH = 16
|
||||
|
||||
|
||||
class ClanError(Exception):
|
||||
pass
|
||||
@@ -55,8 +59,8 @@ class Identity:
|
||||
|
||||
def node_id(self) -> str:
|
||||
nid = self.public.split(":")[0]
|
||||
if len(nid) != 10:
|
||||
msg = f"node_id must be 10 characters long, got {len(nid)}: {nid}"
|
||||
if len(nid) != NODE_ID_LENGTH:
|
||||
msg = f"node_id must be {NODE_ID_LENGTH} characters long, got {len(nid)}: {nid}"
|
||||
raise ClanError(msg)
|
||||
return nid
|
||||
|
||||
@@ -173,8 +177,8 @@ def create_identity() -> Identity:
|
||||
|
||||
|
||||
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
||||
if len(network_id) != 16:
|
||||
msg = f"network_id must be 16 characters long, got '{network_id}'"
|
||||
if len(network_id) != NETWORK_ID_LENGTH:
|
||||
msg = f"network_id must be {NETWORK_ID_LENGTH} characters long, got '{network_id}'"
|
||||
raise ClanError(msg)
|
||||
nwid = int(network_id, 16)
|
||||
node_id = int(identity.node_id(), 16)
|
||||
|
||||
5
nixosModules/clanCore/zerotier/genmoon.py
Normal file → Executable file
5
nixosModules/clanCore/zerotier/genmoon.py
Normal file → Executable file
@@ -6,9 +6,12 @@ import sys
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
# Constants
|
||||
REQUIRED_ARGS = 4
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 4:
|
||||
if len(sys.argv) != REQUIRED_ARGS:
|
||||
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
|
||||
sys.exit(1)
|
||||
moon_json_path = sys.argv[1]
|
||||
|
||||
@@ -5,7 +5,7 @@ from contextlib import ExitStack
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from clan_lib.api import ApiResponse
|
||||
from clan_lib.api import ApiError, ApiResponse, ErrorDataClass
|
||||
from clan_lib.api.tasks import WebThread
|
||||
from clan_lib.async_run import set_current_thread_opkey, set_should_cancel
|
||||
|
||||
@@ -43,7 +43,7 @@ class ApiBridge(ABC):
|
||||
|
||||
def process_request(self, request: BackendRequest) -> None:
|
||||
"""Process an API request through the middleware chain."""
|
||||
from .middleware import MiddlewareContext
|
||||
from .middleware import MiddlewareContext # noqa: PLC0415
|
||||
|
||||
with ExitStack() as stack:
|
||||
context = MiddlewareContext(
|
||||
@@ -59,7 +59,7 @@ class ApiBridge(ABC):
|
||||
f"{middleware.__class__.__name__} => {request.method_name}",
|
||||
)
|
||||
middleware.process(context)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
# If middleware fails, handle error
|
||||
self.send_api_error_response(
|
||||
request.op_key or "unknown",
|
||||
@@ -75,8 +75,6 @@ class ApiBridge(ABC):
|
||||
location: list[str],
|
||||
) -> None:
|
||||
"""Send an error response."""
|
||||
from clan_lib.api import ApiError, ErrorDataClass
|
||||
|
||||
error_data = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
|
||||
@@ -91,7 +91,6 @@ def get_system_file(
|
||||
|
||||
def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
||||
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
|
||||
global RESULT
|
||||
RESULT[op_key] = data
|
||||
|
||||
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||
|
||||
@@ -94,10 +94,10 @@ class LoggingMiddleware(Middleware):
|
||||
if self.handler:
|
||||
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
||||
self.handler.new_handler.close()
|
||||
if self.log_f:
|
||||
self.log_f.close()
|
||||
if self.original_ctx:
|
||||
set_async_ctx(self.original_ctx)
|
||||
if self.log_f:
|
||||
self.log_f.close()
|
||||
|
||||
# Register the logging context manager
|
||||
self.register_context_manager(context, LoggingContextManager(log_file))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,6 +17,7 @@ from clan_app.api.middleware import (
|
||||
LoggingMiddleware,
|
||||
MethodExecutionMiddleware,
|
||||
)
|
||||
from clan_app.deps.http.http_server import HttpApiServer
|
||||
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -64,8 +66,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
# Start HTTP API server if requested
|
||||
http_server = None
|
||||
if app_opts.http_api:
|
||||
from clan_app.deps.http.http_server import HttpApiServer
|
||||
|
||||
openapi_file = os.getenv("OPENAPI_FILE", None)
|
||||
swagger_dist = os.getenv("SWAGGER_UI_DIST", None)
|
||||
|
||||
@@ -95,8 +95,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
log.info("Press Ctrl+C to stop the server")
|
||||
try:
|
||||
# Keep the main thread alive
|
||||
import time
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -148,8 +148,8 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(file_data)
|
||||
except Exception as e:
|
||||
log.error(f"Error reading Swagger file: {e!s}")
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
log.exception("Error reading Swagger file")
|
||||
self.send_error(500, "Internal Server Error")
|
||||
|
||||
def _get_swagger_file_path(self, rel_path: str) -> Path:
|
||||
@@ -191,13 +191,13 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
|
||||
return file_data
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
def do_OPTIONS(self) -> None:
|
||||
"""Handle CORS preflight requests."""
|
||||
self.send_response_only(200)
|
||||
self._send_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
def do_GET(self) -> None:
|
||||
"""Handle GET requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
@@ -211,7 +211,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
def do_POST(self) -> None:
|
||||
"""Handle POST requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
@@ -252,7 +252,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
gen_op_key = str(uuid.uuid4())
|
||||
try:
|
||||
self._handle_api_request(method_name, request_data, gen_op_key)
|
||||
except Exception as e:
|
||||
except RuntimeError as e:
|
||||
log.exception(f"Error processing API request {method_name}")
|
||||
self.send_api_error_response(
|
||||
gen_op_key,
|
||||
@@ -264,10 +264,10 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
"""Read and parse the request body. Returns None if there was an error."""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
body = self.rfile.read(content_length)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
return {}
|
||||
if content_length == 0:
|
||||
return {}
|
||||
body = self.rfile.read(content_length)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
self.send_api_error_response(
|
||||
"post",
|
||||
@@ -275,7 +275,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
["http_bridge", "POST", method_name],
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
except (OSError, ValueError, UnicodeDecodeError) as e:
|
||||
self.send_api_error_response(
|
||||
"post",
|
||||
f"Error reading request: {e!s}",
|
||||
@@ -305,7 +305,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
op_key=op_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
self.send_api_error_response(
|
||||
gen_op_key,
|
||||
str(e),
|
||||
|
||||
@@ -4,13 +4,11 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import pytest
|
||||
from clan_lib.api import MethodRegistry, tasks
|
||||
from clan_lib.async_run import is_async_cancelled
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
from clan_app.api.middleware import (
|
||||
ArgumentParsingMiddleware,
|
||||
@@ -53,31 +51,20 @@ def mock_api() -> MethodRegistry:
|
||||
return api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_log_manager() -> Mock:
|
||||
"""Create a mock log manager."""
|
||||
log_manager = Mock(spec=LogManager)
|
||||
log_manager.create_log_file.return_value.get_file_path.return_value = Mock()
|
||||
log_manager.create_log_file.return_value.get_file_path.return_value.open.return_value = Mock()
|
||||
return log_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_bridge(
|
||||
mock_api: MethodRegistry,
|
||||
mock_log_manager: Mock,
|
||||
) -> tuple[MethodRegistry, tuple]:
|
||||
"""Create HTTP bridge dependencies for testing."""
|
||||
middleware_chain = (
|
||||
ArgumentParsingMiddleware(api=mock_api),
|
||||
# LoggingMiddleware(log_manager=mock_log_manager),
|
||||
MethodExecutionMiddleware(api=mock_api),
|
||||
)
|
||||
return mock_api, middleware_chain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer:
|
||||
def http_server(mock_api: MethodRegistry) -> HttpApiServer:
|
||||
"""Create HTTP server with mock dependencies."""
|
||||
server = HttpApiServer(
|
||||
api=mock_api,
|
||||
@@ -87,7 +74,6 @@ def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServ
|
||||
|
||||
# Add middleware
|
||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||
|
||||
# Bridge will be created automatically when accessed
|
||||
@@ -114,7 +100,6 @@ class TestHttpBridge:
|
||||
# The actual HTTP handling will be tested through the server integration tests
|
||||
assert len(middleware_chain) == 2
|
||||
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
||||
# assert isinstance(middleware_chain[1], LoggingMiddleware)
|
||||
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
||||
|
||||
|
||||
@@ -151,14 +136,14 @@ class TestHttpApiServer:
|
||||
|
||||
try:
|
||||
# Test root endpoint
|
||||
response = urlopen("http://127.0.0.1:8081/") # noqa: S310
|
||||
response = urlopen("http://127.0.0.1:8081/")
|
||||
data: dict = json.loads(response.read().decode())
|
||||
assert data["body"]["status"] == "success"
|
||||
assert data["body"]["data"]["message"] == "Clan API Server"
|
||||
assert data["body"]["data"]["version"] == "1.0.0"
|
||||
|
||||
# Test methods endpoint
|
||||
response = urlopen("http://127.0.0.1:8081/api/methods") # noqa: S310
|
||||
response = urlopen("http://127.0.0.1:8081/api/methods")
|
||||
data = json.loads(response.read().decode())
|
||||
assert data["body"]["status"] == "success"
|
||||
assert "test_method" in data["body"]["data"]["methods"]
|
||||
@@ -194,7 +179,7 @@ class TestHttpApiServer:
|
||||
try:
|
||||
# Test 404 error
|
||||
|
||||
res = urlopen("http://127.0.0.1:8081/nonexistent") # noqa: S310
|
||||
res = urlopen("http://127.0.0.1:8081/nonexistent")
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
@@ -259,7 +244,6 @@ class TestIntegration:
|
||||
def test_full_request_flow(
|
||||
self,
|
||||
mock_api: MethodRegistry,
|
||||
mock_log_manager: Mock,
|
||||
) -> None:
|
||||
"""Test complete request flow from server to bridge to middleware."""
|
||||
server: HttpApiServer = HttpApiServer(
|
||||
@@ -270,7 +254,6 @@ class TestIntegration:
|
||||
|
||||
# Add middleware
|
||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||
|
||||
# Bridge will be created automatically when accessed
|
||||
@@ -306,7 +289,6 @@ class TestIntegration:
|
||||
def test_blocking_task(
|
||||
self,
|
||||
mock_api: MethodRegistry,
|
||||
mock_log_manager: Mock,
|
||||
) -> None:
|
||||
shared_threads: dict[str, tasks.WebThread] = {}
|
||||
tasks.BAKEND_THREADS = shared_threads
|
||||
@@ -321,7 +303,6 @@ class TestIntegration:
|
||||
|
||||
# Add middleware
|
||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||
|
||||
# Start server
|
||||
|
||||
@@ -12,12 +12,11 @@ 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_bridge import WebviewBridge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clan_app.api.middleware import Middleware
|
||||
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -49,7 +48,7 @@ class Webview:
|
||||
shared_threads: dict[str, WebThread] | None = None
|
||||
|
||||
# initialized later
|
||||
_bridge: "WebviewBridge | None" = None
|
||||
_bridge: WebviewBridge | None = None
|
||||
_handle: Any | None = None
|
||||
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||
_middleware: list["Middleware"] = field(default_factory=list)
|
||||
@@ -81,7 +80,7 @@ class Webview:
|
||||
msg = message_queue.get() # Blocks until available
|
||||
js_code = f"window.notifyBus({json.dumps(msg)});"
|
||||
self.eval(js_code)
|
||||
except Exception as e:
|
||||
except (json.JSONDecodeError, RuntimeError, AttributeError) as e:
|
||||
print("Bridge notify error:", e)
|
||||
sleep(0.01) # avoid busy loop
|
||||
|
||||
@@ -132,10 +131,8 @@ class Webview:
|
||||
|
||||
self._middleware.append(middleware)
|
||||
|
||||
def create_bridge(self) -> "WebviewBridge":
|
||||
def create_bridge(self) -> WebviewBridge:
|
||||
"""Create and initialize the WebviewBridge with current middleware."""
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
||||
if self.shared_threads is not None:
|
||||
bridge = WebviewBridge(
|
||||
@@ -211,7 +208,7 @@ class Webview:
|
||||
try:
|
||||
result = callback(*args)
|
||||
success = True
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
result = str(e)
|
||||
success = False
|
||||
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
|
||||
|
||||
@@ -8,8 +8,6 @@ from clan_lib.api.tasks import WebThread
|
||||
|
||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||
|
||||
from .webview import FuncStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .webview import Webview
|
||||
|
||||
@@ -32,6 +30,9 @@ class WebviewBridge(ApiBridge):
|
||||
)
|
||||
|
||||
log.debug(f"Sending response: {serialized}")
|
||||
# Import FuncStatus locally to avoid circular import
|
||||
from .webview import FuncStatus # noqa: PLC0415
|
||||
|
||||
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
|
||||
|
||||
def handle_webview_call(
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
@apply pl-3;
|
||||
}
|
||||
|
||||
&.hasIcon svg.icon {
|
||||
@apply relative top-0.5;
|
||||
}
|
||||
|
||||
&.hasDismiss {
|
||||
@apply pr-3;
|
||||
}
|
||||
@@ -35,6 +31,10 @@
|
||||
&.noPadding {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
svg {
|
||||
@apply relative top-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.alertContent {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const Button = (props: ButtonProps) => {
|
||||
},
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled || props.loading}
|
||||
{...other}
|
||||
>
|
||||
<Loader
|
||||
@@ -90,7 +91,6 @@ export const Button = (props: ButtonProps) => {
|
||||
<Typography
|
||||
class="label"
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size={local.size || "default"}
|
||||
inverted={local.hierarchy === "primary"}
|
||||
weight="bold"
|
||||
|
||||
@@ -164,17 +164,26 @@ export const MachineTags = (props: MachineTagsProps) => {
|
||||
<For each={state.selectedOptions()}>
|
||||
{(option) => (
|
||||
<Tag
|
||||
label={option.value}
|
||||
inverted={props.inverted}
|
||||
action={
|
||||
option.disabled || props.disabled || props.readOnly
|
||||
? undefined
|
||||
: {
|
||||
icon: "Close",
|
||||
onClick: () => state.remove(option),
|
||||
}
|
||||
interactive={
|
||||
!(option.disabled || props.disabled || props.readOnly)
|
||||
}
|
||||
/>
|
||||
icon={({ inverted }) =>
|
||||
option.disabled ||
|
||||
props.disabled ||
|
||||
props.readOnly ? undefined : (
|
||||
<Icon
|
||||
role="button"
|
||||
icon={"Close"}
|
||||
size="0.5rem"
|
||||
inverted={inverted}
|
||||
onClick={() => state.remove(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.value}
|
||||
</Tag>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!props.readOnly}>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
.modal_body {
|
||||
overflow-y: auto;
|
||||
@apply rounded-b-md p-6 pt-4 bg-def-1 flex-grow;
|
||||
@apply rounded-b-md p-4 pt-4 bg-def-1 flex-grow;
|
||||
|
||||
&[data-no-padding] {
|
||||
@apply p-0;
|
||||
|
||||
@@ -2,20 +2,33 @@ import Icon from "../Icon/Icon";
|
||||
import { Button } from "../Button/Button";
|
||||
import styles from "./Search.module.css";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { createMemo, createSignal, For, JSX } from "solid-js";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
JSX,
|
||||
Match,
|
||||
Switch,
|
||||
} from "solid-js";
|
||||
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
|
||||
import { CollectionNode } from "@kobalte/core/*";
|
||||
import cx from "classnames";
|
||||
import { Loader } from "../Loader/Loader";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ItemRenderOptions {
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface SearchMultipleProps<T> {
|
||||
values: T[]; // controlled values
|
||||
onChange: (values: T[]) => void;
|
||||
options: T[];
|
||||
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
|
||||
@@ -23,12 +36,17 @@ export interface SearchMultipleProps<T> {
|
||||
placeholder?: string;
|
||||
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
|
||||
height: string; // e.g. '14.5rem'
|
||||
headerClass?: string;
|
||||
headerChildren?: JSX.Element;
|
||||
loading?: boolean;
|
||||
loadingComponent?: JSX.Element;
|
||||
divider?: boolean;
|
||||
}
|
||||
export function SearchMultiple<T extends Option>(
|
||||
props: SearchMultipleProps<T>,
|
||||
) {
|
||||
// Controlled input value, to allow resetting the input itself
|
||||
const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
||||
// const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
||||
const [inputValue, setInputValue] = createSignal<string>("");
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
@@ -54,30 +72,32 @@ export function SearchMultiple<T extends Option>(
|
||||
return item?.rawValue?.value || `item-${index}`;
|
||||
},
|
||||
estimateSize: () => 42,
|
||||
gap: 6,
|
||||
gap: 0,
|
||||
overscan: 5,
|
||||
...props.virtualizerOptions,
|
||||
});
|
||||
|
||||
return newVirtualizer;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
console.log("multi values:", props.values);
|
||||
});
|
||||
return (
|
||||
<Combobox<T>
|
||||
multiple
|
||||
value={values()}
|
||||
value={props.values}
|
||||
onChange={(values) => {
|
||||
setValues(() => values);
|
||||
// setInputValue(value ? value.label : "");
|
||||
// setValues(() => values);
|
||||
console.log("onChange", values);
|
||||
props.onChange(values);
|
||||
}}
|
||||
class={styles.searchContainer}
|
||||
style={{ "--container-height": props.height }}
|
||||
placement="bottom-start"
|
||||
options={props.options}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
@@ -89,69 +109,78 @@ export function SearchMultiple<T extends Option>(
|
||||
triggerMode="manual"
|
||||
noResetInputOnBlur={true}
|
||||
>
|
||||
<Combobox.Control<T> class={styles.searchHeader}>
|
||||
<Combobox.Control<T>
|
||||
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
|
||||
>
|
||||
{(state) => (
|
||||
<div class={styles.inputContainer}>
|
||||
<Icon icon="Search" color="quaternary" />
|
||||
<Combobox.Input
|
||||
ref={(el) => {
|
||||
inputEl = el;
|
||||
}}
|
||||
class={styles.searchInput}
|
||||
placeholder={props.placeholder}
|
||||
value={inputValue()}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="reset"
|
||||
hierarchy="primary"
|
||||
size="s"
|
||||
ghost
|
||||
icon="CloseCircle"
|
||||
onClick={() => {
|
||||
state.clear();
|
||||
setInputValue("");
|
||||
<>
|
||||
{props.headerChildren}
|
||||
<div class={styles.inputContainer}>
|
||||
<Icon icon="Search" color="quaternary" />
|
||||
<Combobox.Input
|
||||
ref={(el) => {
|
||||
inputEl = el;
|
||||
}}
|
||||
class={styles.searchInput}
|
||||
placeholder={props.placeholder}
|
||||
value={inputValue()}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="reset"
|
||||
hierarchy="primary"
|
||||
size="s"
|
||||
ghost
|
||||
icon="CloseCircle"
|
||||
onClick={() => {
|
||||
state.clear();
|
||||
setInputValue("");
|
||||
|
||||
// Dispatch an input event to notify combobox listeners
|
||||
inputEl.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
// Dispatch an input event to notify combobox listeners
|
||||
inputEl.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content
|
||||
class={styles.searchContent}
|
||||
tabIndex={-1}
|
||||
style={{ "--container-height": props.height }}
|
||||
>
|
||||
<Combobox.Listbox<T>
|
||||
ref={(el) => {
|
||||
listboxRef = el;
|
||||
}}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
"overflow-y": "auto",
|
||||
}}
|
||||
scrollToItem={(key) => {
|
||||
const idx = comboboxItems().findIndex(
|
||||
(option) => option.rawValue.value === key,
|
||||
);
|
||||
virtualizer().scrollToIndex(idx);
|
||||
}}
|
||||
>
|
||||
{(items) => {
|
||||
// Update the virtualizer with the filtered items
|
||||
const arr = Array.from(items());
|
||||
setComboboxItems(arr);
|
||||
<Combobox.Listbox<T>
|
||||
ref={(el) => {
|
||||
listboxRef = el;
|
||||
}}
|
||||
style={{
|
||||
height: props.height,
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
"overflow-y": "auto",
|
||||
}}
|
||||
scrollToItem={(key) => {
|
||||
const idx = comboboxItems().findIndex(
|
||||
(option) => option.rawValue.value === key,
|
||||
);
|
||||
virtualizer().scrollToIndex(idx);
|
||||
}}
|
||||
class={styles.listbox}
|
||||
>
|
||||
{(items) => {
|
||||
// Update the virtualizer with the filtered items
|
||||
const arr = Array.from(items());
|
||||
setComboboxItems(arr);
|
||||
|
||||
return (
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.loading}>
|
||||
{props.loadingComponent ?? (
|
||||
<div class="flex w-full justify-center py-2">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={!props.loading}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer().getTotalSize()}px`,
|
||||
@@ -169,11 +198,16 @@ export function SearchMultiple<T extends Option>(
|
||||
return null;
|
||||
}
|
||||
const isSelected = () =>
|
||||
values().some((v) => v.value === item.rawValue.value);
|
||||
props.values.some(
|
||||
(v) => v.value === item.rawValue.value,
|
||||
);
|
||||
return (
|
||||
<Combobox.Item
|
||||
item={item}
|
||||
class={styles.searchItem}
|
||||
class={cx(
|
||||
styles.searchItem,
|
||||
props.divider && styles.hasDivider,
|
||||
)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -185,17 +219,19 @@ export function SearchMultiple<T extends Option>(
|
||||
>
|
||||
{props.renderItem(item.rawValue, {
|
||||
selected: isSelected(),
|
||||
disabled: item.disabled,
|
||||
})}
|
||||
</Combobox.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Combobox.Listbox>
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
</Combobox.Listbox>
|
||||
{/* </Combobox.Content> */}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.searchHeader {
|
||||
@apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50;
|
||||
@apply flex gap-2 items-center p-2 rounded-t-md z-50;
|
||||
@apply px-3 pt-3 pb-2;
|
||||
}
|
||||
|
||||
@@ -42,18 +42,33 @@
|
||||
}
|
||||
|
||||
.searchItem {
|
||||
&[data-highlighted],
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
@apply bg-inv-acc-2;
|
||||
@apply flex flex-col justify-center overflow-hidden;
|
||||
|
||||
&.hasDivider {
|
||||
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply bg-inv-acc-3;
|
||||
/* Next element is hovered */
|
||||
&:has(+ &:hover) {
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
@apply flex flex-col justify-center;
|
||||
&:not([aria-disabled="true"])[data-highlighted],
|
||||
&:not([aria-disabled="true"]):focus,
|
||||
&:not([aria-disabled="true"]):focus-visible,
|
||||
&:not([aria-disabled="true"]):hover {
|
||||
@apply bg-inv-acc-2 rounded-md;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&:not([aria-disabled="true"]):active {
|
||||
@apply bg-inv-acc-3 rounded-md;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
@@ -61,16 +76,14 @@
|
||||
|
||||
@apply rounded-lg;
|
||||
|
||||
height: var(--container-height, 14.5rem);
|
||||
|
||||
border: 1px solid #2b4647;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%),
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%,
|
||||
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
|
||||
theme(colors.bg.inv.2) 0%,
|
||||
theme(colors.bg.inv.3) 100%
|
||||
);
|
||||
|
||||
box-shadow:
|
||||
@@ -78,10 +91,8 @@
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.searchContent {
|
||||
@apply px-3;
|
||||
height: var(--container-height, 14.5rem);
|
||||
padding-bottom: 4rem;
|
||||
.listbox {
|
||||
@apply px-3 pt-3.5;
|
||||
}
|
||||
|
||||
@keyframes contentHide {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SearchMultiple,
|
||||
SearchMultipleProps,
|
||||
} from "./MultipleSearch";
|
||||
import { JSX, Show } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Search",
|
||||
@@ -55,8 +55,8 @@ function generateModules(count: number): Module[] {
|
||||
modules.push({
|
||||
value: `lolcat/module-${i + 1}`,
|
||||
label: `Module ${i + 1}`,
|
||||
description: `${greek[i % greek.length]}#${i + 1}`,
|
||||
input: "lolcat",
|
||||
description: `${greek[i % greek.length]}#${i + 1} this is a very long description to test text wrapping in the search component`,
|
||||
input: "lolcat-flake-part-from-nixpkgs-via-nix-via-clan-flake",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,12 +72,13 @@ export interface Module {
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
height: "14.5rem",
|
||||
// Test with lots of modules
|
||||
options: generateModules(1000),
|
||||
renderItem: (item: Module) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-white">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||
<Icon icon="Code" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
@@ -94,8 +95,12 @@ export const Default: Story = {
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span>by {item.input}</span>
|
||||
<span class="inline-block max-w-72 truncate align-middle">
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-20 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +109,7 @@ export const Default: Story = {
|
||||
},
|
||||
render: (args: SearchProps<Module>) => {
|
||||
return (
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<div class="fixed bottom-10 left-1/2 mb-2 w-[30rem] -translate-x-1/2">
|
||||
<Search<Module>
|
||||
{...args}
|
||||
onChange={(module) => {
|
||||
@@ -117,32 +122,43 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
height: "14.5rem",
|
||||
// Test with lots of modules
|
||||
loading: true,
|
||||
options: [],
|
||||
renderItem: () => <span></span>,
|
||||
},
|
||||
render: (args: SearchProps<Module>) => {
|
||||
return (
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<Search<Module>
|
||||
{...args}
|
||||
onChange={(module) => {
|
||||
// Go to the module configuration
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
type MachineOrTag =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
type: "machine";
|
||||
disabled?: boolean;
|
||||
}
|
||||
| {
|
||||
members: string[];
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
type: "tag";
|
||||
};
|
||||
|
||||
interface WrapIfProps {
|
||||
condition: boolean;
|
||||
wrapper: (children: JSX.Element) => JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
const WrapIf = (props: WrapIfProps) => {
|
||||
if (props.condition) {
|
||||
return props.wrapper(props.children);
|
||||
} else {
|
||||
return props.children;
|
||||
}
|
||||
};
|
||||
|
||||
const machinesAndTags: MachineOrTag[] = [
|
||||
{ value: "machine-1", label: "Machine 1", type: "machine" },
|
||||
{ value: "machine-2", label: "Machine 2", type: "machine" },
|
||||
@@ -183,7 +199,13 @@ export const Multiple: Story = {
|
||||
</Show>
|
||||
</Combobox.ItemIndicator>
|
||||
<Combobox.ItemLabel class="flex items-center gap-2">
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
color={opts.disabled ? "quaternary" : "primary"}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Show when={item.type === "tag" && item}>
|
||||
@@ -216,6 +238,7 @@ export const Multiple: Story = {
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<SearchMultiple<MachineOrTag>
|
||||
{...args}
|
||||
divider
|
||||
height="20rem"
|
||||
virtualizerOptions={{
|
||||
estimateSize: () => 38,
|
||||
|
||||
@@ -2,20 +2,29 @@ import Icon from "../Icon/Icon";
|
||||
import { Button } from "../Button/Button";
|
||||
import styles from "./Search.module.css";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { createMemo, createSignal, For, JSX } from "solid-js";
|
||||
import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
|
||||
import { createVirtualizer } from "@tanstack/solid-virtual";
|
||||
import { CollectionNode } from "@kobalte/core/*";
|
||||
import { Loader } from "../Loader/Loader";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchProps<T> {
|
||||
onChange: (value: T | null) => void;
|
||||
options: T[];
|
||||
renderItem: (item: T) => JSX.Element;
|
||||
renderItem: (item: T, opts: { disabled: boolean }) => JSX.Element;
|
||||
loading?: boolean;
|
||||
loadingComponent?: JSX.Element;
|
||||
headerClass?: string;
|
||||
height: string; // e.g. '14.5rem'
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
// Controlled input value, to allow resetting the input itself
|
||||
const [value, setValue] = createSignal<T | null>(null);
|
||||
@@ -59,13 +68,14 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
setInputValue(value ? value.label : "");
|
||||
props.onChange(value);
|
||||
}}
|
||||
class={styles.searchContainer}
|
||||
class={cx(styles.searchContainer, props.divider && styles.hasDivider)}
|
||||
placement="bottom-start"
|
||||
options={props.options}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
placeholder="Search a service"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
@@ -77,7 +87,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
triggerMode="manual"
|
||||
noResetInputOnBlur={true}
|
||||
>
|
||||
<Combobox.Control<T> class={styles.searchHeader}>
|
||||
<Combobox.Control<T>
|
||||
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
|
||||
>
|
||||
{(state) => (
|
||||
<div class={styles.inputContainer}>
|
||||
<Icon icon="Search" color="quaternary" />
|
||||
@@ -111,31 +123,39 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
|
||||
<Combobox.Listbox<T>
|
||||
ref={(el) => {
|
||||
listboxRef = el;
|
||||
}}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
"overflow-y": "auto",
|
||||
}}
|
||||
scrollToItem={(key) => {
|
||||
const idx = comboboxItems().findIndex(
|
||||
(option) => option.rawValue.value === key,
|
||||
);
|
||||
virtualizer().scrollToIndex(idx);
|
||||
}}
|
||||
>
|
||||
{(items) => {
|
||||
// Update the virtualizer with the filtered items
|
||||
const arr = Array.from(items());
|
||||
setComboboxItems(arr);
|
||||
<Combobox.Listbox<T>
|
||||
ref={(el) => {
|
||||
listboxRef = el;
|
||||
}}
|
||||
style={{
|
||||
height: props.height,
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
"overflow-y": "auto",
|
||||
}}
|
||||
class={styles.listbox}
|
||||
scrollToItem={(key) => {
|
||||
const idx = comboboxItems().findIndex(
|
||||
(option) => option.rawValue.value === key,
|
||||
);
|
||||
virtualizer().scrollToIndex(idx);
|
||||
}}
|
||||
>
|
||||
{(items) => {
|
||||
// Update the virtualizer with the filtered items
|
||||
const arr = Array.from(items());
|
||||
setComboboxItems(arr);
|
||||
|
||||
return (
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.loading}>
|
||||
{props.loadingComponent ?? (
|
||||
<div class="flex w-full justify-center py-2">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={!props.loading}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer().getTotalSize()}px`,
|
||||
@@ -165,17 +185,19 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{props.renderItem(item.rawValue)}
|
||||
{props.renderItem(item.rawValue, {
|
||||
disabled: item.disabled,
|
||||
})}
|
||||
</Combobox.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Combobox.Listbox>
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
</Combobox.Listbox>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
20
pkgs/clan-app/ui/src/components/Search/TagSelect.module.css
Normal file
20
pkgs/clan-app/ui/src/components/Search/TagSelect.module.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.trigger {
|
||||
@apply rounded-md bg-inv-4 w-full min-h-11;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply outline outline-def-1 outline-1;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--clr-bg-inv-acc-3, #2c4347) 0%,
|
||||
var(--clr-bg-inv-acc-2, #4f747a) 100%
|
||||
),
|
||||
var(--clr-bg-inv-4, #203637);
|
||||
box-shadow: 0 0 0 2px var(--clr-bg-inv-acc-4, #162324) inset;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply bg-inv-acc-4;
|
||||
}
|
||||
}
|
||||
62
pkgs/clan-app/ui/src/components/Search/TagSelect.stories.tsx
Normal file
62
pkgs/clan-app/ui/src/components/Search/TagSelect.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
|
||||
import { TagSelect, TagSelectProps } from "./TagSelect";
|
||||
import { Tag } from "../Tag/Tag";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Custom/SelectStepper",
|
||||
component: TagSelect,
|
||||
} satisfies Meta<TagSelectProps<string>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
interface Item {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type Story = StoryObj<TagSelectProps<Item>>;
|
||||
|
||||
const Item = (item: Item) => (
|
||||
<Tag
|
||||
inverted
|
||||
icon={(tag) => (
|
||||
<Icon icon={"Machine"} size="0.5rem" inverted={tag.inverted} />
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Tag>
|
||||
);
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
renderItem: Item,
|
||||
label: "Peer",
|
||||
options: [
|
||||
{ value: "foo", label: "Foo" },
|
||||
{ value: "bar", label: "Bar" },
|
||||
{ value: "baz", label: "Baz" },
|
||||
{ value: "qux", label: "Qux" },
|
||||
{ value: "quux", label: "Quux" },
|
||||
{ value: "corge", label: "Corge" },
|
||||
{ value: "grault", label: "Grault" },
|
||||
],
|
||||
} satisfies Partial<TagSelectProps<Item>>,
|
||||
render: (args: TagSelectProps<Item>) => {
|
||||
const [state, setState] = createSignal<Item[]>([]);
|
||||
return (
|
||||
<TagSelect<Item>
|
||||
{...args}
|
||||
values={state()}
|
||||
onClick={() => {
|
||||
console.log("Clicked, current values:");
|
||||
setState(() => [
|
||||
{ value: "baz", label: "Baz" },
|
||||
{ value: "qux", label: "Qux" },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
85
pkgs/clan-app/ui/src/components/Search/TagSelect.tsx
Normal file
85
pkgs/clan-app/ui/src/components/Search/TagSelect.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import { For, JSX, Show } from "solid-js";
|
||||
import styles from "./TagSelect.module.css";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { Button } from "../Button/Button";
|
||||
|
||||
// Base props common to both modes
|
||||
export interface TagSelectProps<T> {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
values: T[];
|
||||
options: T[];
|
||||
renderItem: (item: T) => JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallowly interactive field for selecting multiple tags / machines.
|
||||
* It does only handle click and focus interactions
|
||||
* Displays the selected items as tags
|
||||
*/
|
||||
export function TagSelect<T extends { value: unknown }>(
|
||||
props: TagSelectProps<T>,
|
||||
) {
|
||||
const optionValue = "value";
|
||||
return (
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex w-full items-center gap-2 px-1.5 py-0">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
weight="medium"
|
||||
class="flex gap-2 uppercase"
|
||||
size="xs"
|
||||
inverted
|
||||
color="secondary"
|
||||
>
|
||||
{props.label}
|
||||
</Typography>
|
||||
<Icon icon="Info" color="tertiary" inverted size={11} />
|
||||
<Button
|
||||
icon="Settings"
|
||||
hierarchy="primary"
|
||||
ghost
|
||||
class="ml-auto"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<Combobox<T>
|
||||
multiple
|
||||
optionValue={optionValue}
|
||||
value={props.values}
|
||||
options={props.options}
|
||||
allowsEmptyCollection
|
||||
class="w-full"
|
||||
>
|
||||
<Combobox.Control<T> aria-label="Fruits">
|
||||
{(state) => {
|
||||
return (
|
||||
<Combobox.Trigger
|
||||
tabIndex={1}
|
||||
class={styles.trigger}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 px-2 py-3">
|
||||
<Icon icon="Search" color="quaternary" inverted />
|
||||
<Show when={state.selectedOptions().length === 0}>
|
||||
<Typography
|
||||
color="tertiary"
|
||||
inverted
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
>
|
||||
Select
|
||||
</Typography>
|
||||
</Show>
|
||||
<For each={state.selectedOptions()}>{props.renderItem}</For>
|
||||
</div>
|
||||
</Combobox.Trigger>
|
||||
);
|
||||
}}
|
||||
</Combobox.Control>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
.sidebar {
|
||||
@apply w-60 border-none z-10;
|
||||
|
||||
.body {
|
||||
@apply pt-4 pb-3 px-2;
|
||||
}
|
||||
@apply w-60 border-none z-10 h-full flex flex-col;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import styles from "./Sidebar.module.css";
|
||||
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||
import cx from "classnames";
|
||||
import { splitProps } from "solid-js";
|
||||
|
||||
export interface LinkProps {
|
||||
path: string;
|
||||
@@ -19,10 +20,12 @@ export interface SidebarProps {
|
||||
}
|
||||
|
||||
export const Sidebar = (props: SidebarProps) => {
|
||||
const [bodyProps] = splitProps(props, ["staticSections"]);
|
||||
|
||||
return (
|
||||
<div class={cx(styles.sidebar, props.class)}>
|
||||
<SidebarHeader />
|
||||
<SidebarBody class={cx(styles.body)} {...props} />
|
||||
<SidebarBody {...bodyProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
div.sidebar-pane {
|
||||
@apply border-none z-10;
|
||||
@apply flex flex-col border-none z-20 h-full;
|
||||
|
||||
animation: sidebarPaneShow 250ms ease-in forwards;
|
||||
|
||||
&.open {
|
||||
@apply w-60;
|
||||
@apply w-72;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
@@ -90,7 +90,7 @@ div.sidebar-pane {
|
||||
@apply opacity-100;
|
||||
}
|
||||
100% {
|
||||
@apply w-60;
|
||||
@apply w-72;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
div.sidebar-section {
|
||||
@apply flex flex-col gap-2 w-full h-fit;
|
||||
@apply flex flex-col gap-2 w-full h-full;
|
||||
|
||||
& > div.header {
|
||||
@apply flex items-center justify-between px-1.5;
|
||||
|
||||
@@ -17,7 +17,6 @@ export const SidebarSection = (props: SidebarSectionProps) => {
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
family="mono"
|
||||
weight="light"
|
||||
transform="uppercase"
|
||||
color="tertiary"
|
||||
inverted={true}
|
||||
|
||||
@@ -73,7 +73,6 @@ export function SidebarSectionForm<
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
family="mono"
|
||||
weight="light"
|
||||
transform="uppercase"
|
||||
color="tertiary"
|
||||
inverted
|
||||
|
||||
@@ -19,7 +19,9 @@ span.tag {
|
||||
|
||||
&.has-action {
|
||||
@apply pr-1.5;
|
||||
}
|
||||
|
||||
&.is-interactive {
|
||||
&:hover {
|
||||
@apply bg-def-acc-3;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tag, TagProps } from "@/src/components/Tag/Tag";
|
||||
import { Meta, type StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { expect, fn } from "storybook/test";
|
||||
import { fn } from "storybook/test";
|
||||
import Icon from "../Icon/Icon";
|
||||
|
||||
const meta: Meta<TagProps> = {
|
||||
title: "Components/Tag",
|
||||
@@ -13,27 +14,44 @@ type Story = StoryObj<TagProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: "Label",
|
||||
children: "Label",
|
||||
},
|
||||
};
|
||||
|
||||
const IconAction = ({
|
||||
inverted,
|
||||
handleActionClick,
|
||||
}: {
|
||||
inverted: boolean;
|
||||
handleActionClick: () => void;
|
||||
}) => (
|
||||
<Icon
|
||||
role="button"
|
||||
icon={"Close"}
|
||||
size="0.5rem"
|
||||
onClick={() => {
|
||||
console.log("icon clicked");
|
||||
handleActionClick();
|
||||
fn();
|
||||
}}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
action: {
|
||||
icon: "Close",
|
||||
onClick: fn(),
|
||||
},
|
||||
icon: IconAction,
|
||||
interactive: true,
|
||||
},
|
||||
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await expect(args.action.onClick).toHaveBeenCalled();
|
||||
// await expect(args.icon.onClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Inverted: Story = {
|
||||
args: {
|
||||
label: "Label",
|
||||
children: "Label",
|
||||
inverted: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,18 +2,19 @@ import "./Tag.css";
|
||||
|
||||
import cx from "classnames";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import Icon, { IconVariant } from "../Icon/Icon";
|
||||
import { createSignal, JSX } from "solid-js";
|
||||
|
||||
export interface TagAction {
|
||||
icon: IconVariant;
|
||||
onClick: () => void;
|
||||
interface IconActionProps {
|
||||
inverted: boolean;
|
||||
handleActionClick: () => void;
|
||||
}
|
||||
|
||||
export interface TagProps {
|
||||
label: string;
|
||||
action?: TagAction;
|
||||
export interface TagProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
||||
children?: JSX.Element;
|
||||
icon?: (state: IconActionProps) => JSX.Element;
|
||||
inverted?: boolean;
|
||||
interactive?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const Tag = (props: TagProps) => {
|
||||
@@ -23,7 +24,6 @@ export const Tag = (props: TagProps) => {
|
||||
|
||||
const handleActionClick = () => {
|
||||
setIsActive(true);
|
||||
props.action?.onClick();
|
||||
setTimeout(() => setIsActive(false), 150);
|
||||
};
|
||||
|
||||
@@ -32,23 +32,18 @@ export const Tag = (props: TagProps) => {
|
||||
class={cx("tag", {
|
||||
inverted: inverted(),
|
||||
active: isActive(),
|
||||
"has-action": props.action,
|
||||
"has-icon": props.icon,
|
||||
"is-interactive": props.interactive,
|
||||
class: props.class,
|
||||
})}
|
||||
aria-label={props.label}
|
||||
aria-readonly={!props.action}
|
||||
>
|
||||
<Typography hierarchy="label" size="xs" inverted={inverted()}>
|
||||
{props.label}
|
||||
{props.children}
|
||||
</Typography>
|
||||
<Show when={props.action}>
|
||||
<Icon
|
||||
role="button"
|
||||
icon={props.action!.icon}
|
||||
size="0.5rem"
|
||||
inverted={inverted()}
|
||||
onClick={handleActionClick}
|
||||
/>
|
||||
</Show>
|
||||
{props.icon?.({
|
||||
inverted: inverted(),
|
||||
handleActionClick,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export const TagGroup = (props: TagGroupProps) => {
|
||||
return (
|
||||
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
|
||||
<For each={props.labels}>
|
||||
{(label) => <Tag label={label} inverted={inverted()} />}
|
||||
{(label) => <Tag inverted={inverted()}>{label}</Tag>}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/* Body */
|
||||
.typography {
|
||||
&.weight-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&.weight-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -71,6 +67,12 @@
|
||||
line-height: normal;
|
||||
letter-spacing: 0.008125rem;
|
||||
}
|
||||
|
||||
&.size-xxs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
/* letter-spacing: 0.008125rem; */
|
||||
}
|
||||
}
|
||||
|
||||
&.family-mono {
|
||||
@@ -93,6 +95,11 @@
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
&.size-xxs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
/* letter-spacing: 0.008125rem; */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Color, fgClass } from "@/src/components/colors";
|
||||
|
||||
export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
||||
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
|
||||
export type Weight = "normal" | "medium" | "bold" | "light";
|
||||
export type Weight = "normal" | "medium" | "bold";
|
||||
export type Family = "regular" | "condensed" | "mono";
|
||||
export type Transform = "uppercase" | "lowercase" | "capitalize";
|
||||
|
||||
@@ -87,7 +87,6 @@ const weightMap: Record<Weight, string> = {
|
||||
normal: "weight-normal",
|
||||
medium: "weight-medium",
|
||||
bold: "weight-bold",
|
||||
light: "weight-light",
|
||||
};
|
||||
|
||||
interface _TypographyProps<H extends Hierarchy> {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const DefaultQueryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
export type MachinesQuery = ReturnType<typeof useMachinesQuery>;
|
||||
export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
@@ -117,11 +118,31 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export type TagsQuery = ReturnType<typeof useTags>;
|
||||
export const useTags = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "tags"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("list_tags", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await apiCall.result;
|
||||
if (result.status === "error") {
|
||||
throw new Error("Error fetching tags: " + result.errors[0].message);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
refetchInterval: 1000 * 60, // poll every 60 seconds
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
machine: {
|
||||
@@ -434,12 +455,14 @@ export const useMachineGenerators = (
|
||||
],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_generators", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
machines: [
|
||||
{
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
full_closure: true, // TODO: Make this configurable
|
||||
// TODO: Make this configurable
|
||||
include_previous_values: true,
|
||||
@@ -456,3 +479,53 @@ export const useMachineGenerators = (
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export type ServiceModulesQuery = ReturnType<typeof useServiceModules>;
|
||||
export type ServiceModules = SuccessData<"list_service_modules">;
|
||||
export const useServiceModules = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_modules", {
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
});
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
// todo should we create some specific error types?
|
||||
console.error("Error fetching clan details:", result.errors);
|
||||
throw new Error(result.errors[0].message);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
||||
export type ServiceInstances = SuccessData<"list_service_instances">;
|
||||
export const useServiceInstances = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
});
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
// todo should we create some specific error types?
|
||||
console.error("Error fetching clan details:", result.errors);
|
||||
throw new Error(result.errors[0].message);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { onCleanup } from "solid-js";
|
||||
|
||||
export function useClickOutside(
|
||||
el: () => HTMLElement | undefined,
|
||||
handler: (e: MouseEvent) => void,
|
||||
) {
|
||||
const listener = (e: MouseEvent) => {
|
||||
const element = el();
|
||||
if (element && !element.contains(e.target as Node)) {
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", listener);
|
||||
onCleanup(() => document.removeEventListener("mousedown", listener));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
||||
import { ApiClientProvider } from "./hooks/ApiClient";
|
||||
import { callApi } from "./hooks/api";
|
||||
import { DefaultQueryClient } from "@/src/hooks/queries";
|
||||
import { Toaster } from "solid-toast";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
|
||||
@@ -25,6 +26,8 @@ if (import.meta.env.DEV) {
|
||||
render(
|
||||
() => (
|
||||
<ApiClientProvider client={{ fetch: callApi }}>
|
||||
{/* Temporary solution */}
|
||||
<Toaster toastOptions={{}} />
|
||||
<QueryClientProvider client={DefaultQueryClient}>
|
||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
|
||||
@@ -199,7 +199,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
|
||||
disabled={removeDisabled()}
|
||||
onClick={onRemove}
|
||||
>
|
||||
Remove
|
||||
Remove Clan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,38 @@
|
||||
@apply min-w-96;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply absolute left-4 top-10 w-60;
|
||||
@apply min-h-96;
|
||||
.sidebarContainer {
|
||||
@apply absolute left-4 top-4 w-60 z-10;
|
||||
height: calc(100vh - 2rem);
|
||||
|
||||
height: calc(100vh - 8rem);
|
||||
animation: sidebarNoMachine 250ms ease-in-out;
|
||||
|
||||
&.machineSelected {
|
||||
@apply top-16;
|
||||
height: calc(100vh - 8rem);
|
||||
|
||||
animation: sidebarMachine 250ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sidebarNoMachine {
|
||||
0% {
|
||||
@apply top-16;
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
100% {
|
||||
@apply top-4;
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sidebarMachine {
|
||||
0% {
|
||||
@apply top-4;
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
100% {
|
||||
@apply top-16;
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
buildMachinePath,
|
||||
maybeUseMachineName,
|
||||
useClanURI,
|
||||
useMachineName,
|
||||
} from "@/src/hooks/clan";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||
import {
|
||||
ClanDetails,
|
||||
MachinesQueryResult,
|
||||
@@ -36,6 +37,12 @@ import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
@@ -114,7 +121,13 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Sidebar class={cx(styles.sidebar)} />
|
||||
<div
|
||||
class={cx(styles.sidebarContainer, {
|
||||
[styles.machineSelected]: useMachineName(),
|
||||
})}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{props.children}
|
||||
<ClanSceneController {...props} />
|
||||
</ClanContext.Provider>
|
||||
@@ -179,7 +192,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
||||
const [showService, setShowService] = createSignal(false);
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
} | null>(null);
|
||||
@@ -187,7 +203,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const onCreate = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowModal(true);
|
||||
setDialogHandlers({ resolve, reject });
|
||||
setCurrentPromise({ resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const onAddService = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowService((v) => !v);
|
||||
console.log("setting current promise");
|
||||
setCurrentPromise({ resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -217,8 +241,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
return { id: values.name };
|
||||
};
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
|
||||
const [loadingError, setLoadingError] = createSignal<
|
||||
{ title: string; description: string } | undefined
|
||||
>();
|
||||
@@ -265,6 +287,47 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = useApiClient();
|
||||
const handleSubmitService: SubmitServiceHandler = async (
|
||||
instance,
|
||||
action,
|
||||
) => {
|
||||
console.log(action, "Instance", instance);
|
||||
|
||||
if (action !== "create") {
|
||||
toast.error("Only creating new services is supported");
|
||||
return;
|
||||
}
|
||||
const call = client.fetch("create_service_instance", {
|
||||
flake: {
|
||||
identifier: ctx.clanURI,
|
||||
},
|
||||
module_ref: instance.module,
|
||||
roles: instance.roles,
|
||||
});
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
toast.error("Error creating service instance");
|
||||
console.error("Error creating service instance", result.errors);
|
||||
}
|
||||
toast.success("Created");
|
||||
//
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
setShowService(false);
|
||||
};
|
||||
|
||||
createEffect(
|
||||
on(worldMode, (mode) => {
|
||||
if (mode === "service") {
|
||||
setShowService(true);
|
||||
} else {
|
||||
// todo: request close instead of force close
|
||||
setShowService(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={loadingError()}>
|
||||
@@ -274,15 +337,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
<MockCreateMachine
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
dialogHandlers()?.reject(new Error("User cancelled"));
|
||||
currentPromise()?.reject(new Error("User cancelled"));
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const result = await sendCreate(values);
|
||||
dialogHandlers()?.resolve(result);
|
||||
currentPromise()?.resolve(result);
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
dialogHandlers()?.reject(err);
|
||||
currentPromise()?.reject(err);
|
||||
setShowModal(false);
|
||||
}
|
||||
}}
|
||||
@@ -301,22 +364,39 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
onSelect={onMachineSelect}
|
||||
isLoading={ctx.isLoading()}
|
||||
cubesQuery={ctx.machinesQuery}
|
||||
toolbarPopup={
|
||||
<Show when={showService()}>
|
||||
<ServiceWorkflow
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
setShowService(false);
|
||||
setWorldMode("default");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
onCreate={onCreate}
|
||||
clanURI={ctx.clanURI}
|
||||
sceneStore={() => store.sceneData?.[ctx.clanURI]}
|
||||
setMachinePos={(machineId: string, pos: [number, number]) => {
|
||||
setMachinePos={(machineId: string, pos: [number, number] | null) => {
|
||||
console.log("calling setStore", machineId, pos);
|
||||
setStore(
|
||||
produce((s) => {
|
||||
if (!s.sceneData) {
|
||||
s.sceneData = {};
|
||||
}
|
||||
if (!s.sceneData[ctx.clanURI]) {
|
||||
s.sceneData[ctx.clanURI] = {};
|
||||
}
|
||||
if (!s.sceneData[ctx.clanURI][machineId]) {
|
||||
s.sceneData[ctx.clanURI][machineId] = { position: pos };
|
||||
if (!s.sceneData) s.sceneData = {};
|
||||
|
||||
if (!s.sceneData[ctx.clanURI]) s.sceneData[ctx.clanURI] = {};
|
||||
|
||||
if (pos === null) {
|
||||
// Remove the machine entry if pos is null
|
||||
Reflect.deleteProperty(s.sceneData[ctx.clanURI], machineId);
|
||||
|
||||
if (Object.keys(s.sceneData[ctx.clanURI]).length === 0) {
|
||||
Reflect.deleteProperty(s.sceneData, ctx.clanURI);
|
||||
}
|
||||
} else {
|
||||
s.sceneData[ctx.clanURI][machineId].position = pos;
|
||||
// Set or update the machine position
|
||||
s.sceneData[ctx.clanURI][machineId] = { position: pos };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.sidebarPane {
|
||||
@apply absolute left-[16.5rem] top-12 w-64;
|
||||
@apply min-h-96;
|
||||
.sidebarPaneContainer {
|
||||
@apply absolute left-[16.5rem] top-4 w-64 z-20;
|
||||
|
||||
height: calc(100vh - 10rem);
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||
|
||||
import cx from "classnames";
|
||||
import styles from "./Machine.module.css";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
@@ -52,18 +51,19 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||
|
||||
return (
|
||||
<SidebarPane
|
||||
class={cx(styles.sidebarPane)}
|
||||
title={machineName}
|
||||
onClose={onClose}
|
||||
subHeader={() => (
|
||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||
)}
|
||||
>
|
||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</SidebarPane>
|
||||
<div class={styles.sidebarPaneContainer}>
|
||||
<SidebarPane
|
||||
title={machineName}
|
||||
onClose={onClose}
|
||||
subHeader={() => (
|
||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||
)}
|
||||
>
|
||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</SidebarPane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ const welcome = (props: {
|
||||
loading={loading()}
|
||||
onClick={selectFolder}
|
||||
>
|
||||
Select folder
|
||||
Select existing Clan
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,13 @@ import { SceneData } from "../stores/clan";
|
||||
import { MachinesQueryResult } from "../hooks/queries";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
import { highlightGroups } from "./highlightStore";
|
||||
|
||||
function keyFromPos(pos: [number, number]): string {
|
||||
return `${pos[0]},${pos[1]}`;
|
||||
}
|
||||
|
||||
const CUBE_SPACING = 2;
|
||||
const CUBE_SPACING = 1;
|
||||
|
||||
export class MachineManager {
|
||||
public machines = new Map<string, MachineRepr>();
|
||||
@@ -25,50 +26,72 @@ export class MachineManager {
|
||||
machinePositionsSignal: Accessor<SceneData>,
|
||||
machinesQueryResult: MachinesQueryResult,
|
||||
selectedIds: Accessor<Set<string>>,
|
||||
setMachinePos: (id: string, position: [number, number]) => void,
|
||||
setMachinePos: (id: string, position: [number, number] | null) => void,
|
||||
) {
|
||||
this.machinePositionsSignal = machinePositionsSignal;
|
||||
|
||||
this.disposeRoot = createRoot((disposeEffects) => {
|
||||
createEffect(() => {
|
||||
const machines = machinePositionsSignal();
|
||||
|
||||
Object.entries(machines).forEach(([id, data]) => {
|
||||
const machineRepr = new MachineRepr(
|
||||
scene,
|
||||
registry,
|
||||
new THREE.Vector2(data.position[0], data.position[1]),
|
||||
id,
|
||||
selectedIds,
|
||||
);
|
||||
this.machines.set(id, machineRepr);
|
||||
scene.add(machineRepr.group);
|
||||
});
|
||||
renderLoop.requestRender();
|
||||
});
|
||||
|
||||
// Push positions of previously existing machines to the scene
|
||||
// TODO: Maybe we should do this in some post query hook?
|
||||
//
|
||||
// Effect 1: sync query → store (positions)
|
||||
//
|
||||
createEffect(() => {
|
||||
if (!machinesQueryResult.data) return;
|
||||
|
||||
const actualMachines = Object.keys(machinesQueryResult.data);
|
||||
const actualIds = Object.keys(machinesQueryResult.data);
|
||||
const machinePositions = machinePositionsSignal();
|
||||
const placed: Set<string> = machinePositions
|
||||
? new Set(Object.keys(machinePositions))
|
||||
: new Set();
|
||||
|
||||
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
|
||||
|
||||
// Push not explizitly placed machines to the scene
|
||||
// TODO: Make the user place them manually
|
||||
// We just calculate some next free position
|
||||
for (const id of nonPlaced) {
|
||||
console.log("adding", id);
|
||||
const position = this.nextGridPos();
|
||||
|
||||
setMachinePos(id, position);
|
||||
// Remove stale
|
||||
for (const id of Object.keys(machinePositions)) {
|
||||
if (!actualIds.includes(id)) {
|
||||
setMachinePos(id, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing
|
||||
for (const id of actualIds) {
|
||||
if (!machinePositions[id]) {
|
||||
const pos = this.nextGridPos();
|
||||
setMachinePos(id, pos);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Effect 2: sync store → scene
|
||||
//
|
||||
createEffect(() => {
|
||||
const positions = machinePositionsSignal();
|
||||
|
||||
// Remove machines from scene
|
||||
for (const [id, repr] of this.machines) {
|
||||
if (!(id in positions)) {
|
||||
repr.dispose(scene);
|
||||
this.machines.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update machines
|
||||
for (const [id, data] of Object.entries(positions)) {
|
||||
let repr = this.machines.get(id);
|
||||
if (!repr) {
|
||||
repr = new MachineRepr(
|
||||
scene,
|
||||
registry,
|
||||
new THREE.Vector2(data.position[0], data.position[1]),
|
||||
id,
|
||||
selectedIds,
|
||||
highlightGroups,
|
||||
);
|
||||
this.machines.set(id, repr);
|
||||
scene.add(repr.group);
|
||||
} else {
|
||||
repr.setPosition(
|
||||
new THREE.Vector2(data.position[0], data.position[1]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderLoop.requestRender();
|
||||
});
|
||||
|
||||
return disposeEffects;
|
||||
|
||||
@@ -13,6 +13,7 @@ const CUBE_COLOR = 0xe2eff0;
|
||||
const CUBE_EMISSIVE = 0x303030;
|
||||
|
||||
const CUBE_SELECTED_COLOR = 0x4b6767;
|
||||
const HIGHLIGHT_COLOR = 0x00ee66;
|
||||
|
||||
const BASE_COLOR = 0xdbeaeb;
|
||||
const BASE_EMISSIVE = 0x0c0c0c;
|
||||
@@ -36,6 +37,7 @@ export class MachineRepr {
|
||||
position: THREE.Vector2,
|
||||
id: string,
|
||||
selectedSignal: Accessor<Set<string>>,
|
||||
highlightGroups: Record<string, Set<string>>, // Reactive store
|
||||
) {
|
||||
this.id = id;
|
||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||
@@ -89,23 +91,38 @@ export class MachineRepr {
|
||||
|
||||
this.disposeRoot = createRoot((disposeEffects) => {
|
||||
createEffect(
|
||||
on(selectedSignal, (selectedIds) => {
|
||||
const isSelected = selectedIds.has(this.id);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
);
|
||||
on(
|
||||
[selectedSignal, () => Object.entries(highlightGroups)],
|
||||
([selectedIds, groups]) => {
|
||||
const isSelected = selectedIds.has(this.id);
|
||||
const highlightedGroups = groups
|
||||
.filter(([, ids]) => ids.has(this.id))
|
||||
.map(([name]) => name);
|
||||
|
||||
// Update base
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
||||
);
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||
);
|
||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
);
|
||||
|
||||
renderLoop.requestRender();
|
||||
}),
|
||||
// Update base
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
||||
);
|
||||
|
||||
// TOOD: Find a different way to show both selected & highlighted
|
||||
// I.e. via outline or pulsing
|
||||
// selected > highlighted > normal
|
||||
(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();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return disposeEffects;
|
||||
@@ -121,6 +138,11 @@ export class MachineRepr {
|
||||
});
|
||||
}
|
||||
|
||||
public setPosition(position: THREE.Vector2) {
|
||||
this.group.position.set(position.x, 0, position.y);
|
||||
renderLoop.requestRender();
|
||||
}
|
||||
|
||||
private createCubeBase(
|
||||
color: THREE.ColorRepresentation,
|
||||
emissive: THREE.ColorRepresentation,
|
||||
@@ -154,6 +176,14 @@ export class MachineRepr {
|
||||
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||
<Show when={show()}> */
|
||||
.toolbar-container {
|
||||
@apply absolute bottom-10 z-10 w-full;
|
||||
@apply flex justify-center items-center;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
|
||||
import {
|
||||
createSignal,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
onMount,
|
||||
on,
|
||||
JSX,
|
||||
} from "solid-js";
|
||||
import "./cubes.css";
|
||||
|
||||
import * as THREE from "three";
|
||||
@@ -8,7 +15,7 @@ import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import { Toolbar } from "../components/Toolbar/Toolbar";
|
||||
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
|
||||
import { Divider } from "../components/Divider/Divider";
|
||||
import { MachinesQueryResult } from "../hooks/queries";
|
||||
import { MachinesQueryResult, useMachinesQuery } from "../hooks/queries";
|
||||
import { SceneData } from "../stores/clan";
|
||||
import { Accessor } from "solid-js";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
@@ -31,14 +38,45 @@ function garbageCollectGroup(group: THREE.Group) {
|
||||
group.clear(); // Clear the group
|
||||
}
|
||||
|
||||
// Can be imported by others via wrappers below
|
||||
// Global signal for last clicked machine
|
||||
const [lastClickedMachine, setLastClickedMachine] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Exported so others could also emit the signal if needed
|
||||
// And for testing purposes
|
||||
export function emitMachineClick(id: string | null) {
|
||||
setLastClickedMachine(id);
|
||||
if (id) {
|
||||
// Clear after a short delay to allow re-clicking the same machine
|
||||
setTimeout(() => {
|
||||
setLastClickedMachine(null);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook for components to subscribe */
|
||||
export function useMachineClick() {
|
||||
return lastClickedMachine;
|
||||
}
|
||||
|
||||
/*Gloabl signal*/
|
||||
const [worldMode, setWorldMode] = createSignal<
|
||||
"default" | "select" | "service" | "create"
|
||||
>("default");
|
||||
export { worldMode, setWorldMode };
|
||||
|
||||
export function CubeScene(props: {
|
||||
cubesQuery: MachinesQueryResult;
|
||||
onCreate: () => Promise<{ id: string }>;
|
||||
selectedIds: Accessor<Set<string>>;
|
||||
onSelect: (v: Set<string>) => void;
|
||||
sceneStore: Accessor<SceneData>;
|
||||
setMachinePos: (machineId: string, pos: [number, number]) => void;
|
||||
setMachinePos: (machineId: string, pos: [number, number] | null) => void;
|
||||
isLoading: boolean;
|
||||
clanURI: string;
|
||||
toolbarPopup?: JSX.Element;
|
||||
}) {
|
||||
let container: HTMLDivElement;
|
||||
let scene: THREE.Scene;
|
||||
@@ -64,8 +102,6 @@ export function CubeScene(props: {
|
||||
"grid",
|
||||
);
|
||||
|
||||
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
|
||||
|
||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||
|
||||
const [cameraInfo, setCameraInfo] = createSignal({
|
||||
@@ -74,7 +110,7 @@ export function CubeScene(props: {
|
||||
});
|
||||
|
||||
// Grid configuration
|
||||
const GRID_SIZE = 2;
|
||||
const GRID_SIZE = 1;
|
||||
|
||||
const BASE_SIZE = 0.9; // Height of the cube above the ground
|
||||
const CUBE_SIZE = BASE_SIZE / 1.5; //
|
||||
@@ -212,7 +248,8 @@ export function CubeScene(props: {
|
||||
controls = new MapControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
|
||||
controls.mouseButtons.RIGHT = null;
|
||||
controls.enableRotate = false;
|
||||
// controls.rotateSpeed = -0.8;
|
||||
// controls.enableRotate = false;
|
||||
controls.minZoom = 1.2;
|
||||
controls.maxZoom = 3.5;
|
||||
controls.addEventListener("change", () => {
|
||||
@@ -360,7 +397,7 @@ export function CubeScene(props: {
|
||||
);
|
||||
|
||||
// Click handler:
|
||||
// - Select/deselects a cube in "view" mode
|
||||
// - Select/deselects a cube in mode
|
||||
// - Creates a new cube in "create" mode
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (worldMode() === "create") {
|
||||
@@ -381,7 +418,7 @@ export function CubeScene(props: {
|
||||
.finally(() => {
|
||||
if (initBase) initBase.visible = false;
|
||||
|
||||
setWorldMode("view");
|
||||
setWorldMode("default");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,8 +436,13 @@ export function CubeScene(props: {
|
||||
if (intersects.length > 0) {
|
||||
console.log("Clicked on cube:", intersects);
|
||||
const id = intersects[0].object.userData.id;
|
||||
toggleSelection(id);
|
||||
|
||||
if (worldMode() === "select") toggleSelection(id);
|
||||
|
||||
emitMachineClick(id); // notify subscribers
|
||||
} else {
|
||||
emitMachineClick(null);
|
||||
|
||||
props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
|
||||
}
|
||||
};
|
||||
@@ -505,6 +547,7 @@ export function CubeScene(props: {
|
||||
const intersects = raycaster.intersectObject(floor);
|
||||
if (intersects.length > 0) {
|
||||
const point = intersects[0].point;
|
||||
|
||||
// Snap to grid
|
||||
const snapped = new THREE.Vector3(
|
||||
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
||||
@@ -512,6 +555,17 @@ export function CubeScene(props: {
|
||||
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(initBase.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(initBase.position.z - snapped.z) > 0.01
|
||||
@@ -524,23 +578,29 @@ export function CubeScene(props: {
|
||||
}
|
||||
};
|
||||
|
||||
const machinesQuery = useMachinesQuery(props.clanURI);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="cubes-scene-container" ref={(el) => (container = el)} />
|
||||
<div class="toolbar-container">
|
||||
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||
{props.toolbarPopup}
|
||||
</div>
|
||||
<Toolbar>
|
||||
<ToolbarButton
|
||||
description="Select machine"
|
||||
name="Select"
|
||||
icon="Cursor"
|
||||
onClick={() => setWorldMode("view")}
|
||||
selected={worldMode() === "view"}
|
||||
onClick={() =>
|
||||
setWorldMode((v) => (v === "select" ? "default" : "select"))
|
||||
}
|
||||
selected={worldMode() === "select"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
description="Create new machine"
|
||||
name="new-machine"
|
||||
icon="NewMachine"
|
||||
disabled={positionMode() === "circle"}
|
||||
onClick={onAddClick}
|
||||
selected={worldMode() === "create"}
|
||||
/>
|
||||
@@ -548,24 +608,18 @@ export function CubeScene(props: {
|
||||
<ToolbarButton
|
||||
description="Add new Service"
|
||||
name="modules"
|
||||
icon="Modules"
|
||||
icon="Services"
|
||||
selected={worldMode() === "service"}
|
||||
onClick={() => {
|
||||
if (positionMode() === "grid") {
|
||||
setPositionMode("circle");
|
||||
setWorldMode("view");
|
||||
grid.visible = false;
|
||||
} else {
|
||||
setPositionMode("grid");
|
||||
grid.visible = true;
|
||||
}
|
||||
renderLoop.requestRender();
|
||||
setWorldMode((v) => (v === "service" ? "default" : "service"));
|
||||
}}
|
||||
/>
|
||||
{/* <ToolbarButton
|
||||
description="Delete Machine"
|
||||
name="delete"
|
||||
icon="Trash"
|
||||
/> */}
|
||||
<ToolbarButton
|
||||
icon="Reload"
|
||||
name="Reload"
|
||||
description="Reload machines"
|
||||
onClick={() => machinesQuery.refetch()}
|
||||
/>
|
||||
</Toolbar>
|
||||
</div>
|
||||
</>
|
||||
|
||||
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// highlightStore.ts
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
|
||||
// groups: { [groupName: string]: Set<nodeId> }
|
||||
const [highlightGroups, setHighlightGroups] = createStore<
|
||||
Record<string, Set<string>>
|
||||
>({});
|
||||
|
||||
// Add highlight
|
||||
export function highlight(group: string, nodeId: string) {
|
||||
setHighlightGroups(group, (prev = new Set()) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Remove highlight
|
||||
export function unhighlight(group: string, nodeId: string) {
|
||||
setHighlightGroups(group, (prev = new Set()) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear group
|
||||
export function clearHighlight(group: string) {
|
||||
setHighlightGroups(group, () => new Set());
|
||||
}
|
||||
|
||||
export function clearAllHighlights() {
|
||||
setHighlightGroups(
|
||||
produce((s) => {
|
||||
for (const key of Object.keys(s)) {
|
||||
Reflect.deleteProperty(s, key);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { highlightGroups, setHighlightGroups };
|
||||
@@ -52,6 +52,7 @@ export interface InstallStoreType {
|
||||
};
|
||||
install: {
|
||||
targetHost: string;
|
||||
port?: string;
|
||||
machineName: string;
|
||||
mainDisk: string;
|
||||
// ...TODO Vars
|
||||
@@ -96,6 +97,8 @@ export const InstallModal = (props: InstallModalProps) => {
|
||||
switch (currentStep.id) {
|
||||
case "create:progress":
|
||||
case "create:done":
|
||||
case "install:progress":
|
||||
case "install:done":
|
||||
return currentStep.class;
|
||||
|
||||
default:
|
||||
|
||||
@@ -13,7 +13,7 @@ const ChoiceLocalOrRemote = () => {
|
||||
onClick={() => stepSignal.setActiveStep("local:choice")}
|
||||
/>
|
||||
<NavSection
|
||||
label="The Machine is remote and i have ssh access to it"
|
||||
label="The machine is remote and I have ssh access to it"
|
||||
onClick={() => stepSignal.setActiveStep("install:address")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@ import cx from "classnames";
|
||||
const Prose = () => (
|
||||
<StepLayout
|
||||
body={
|
||||
<>
|
||||
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md px-4 py-6 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md p-4 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
@@ -74,7 +74,7 @@ const Prose = () => (
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
footer={<StepFooter nextText="start" />}
|
||||
/>
|
||||
|
||||
@@ -44,6 +44,12 @@ const ConfigureAdressSchema = v.object({
|
||||
v.string("Please set a target host."),
|
||||
v.nonEmpty("Please set a target host."),
|
||||
),
|
||||
port: v.optional(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.transform((val) => (val === "" ? undefined : val)),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
|
||||
@@ -56,6 +62,7 @@ const ConfigureAddress = () => {
|
||||
validate: valiForm(ConfigureAdressSchema),
|
||||
initialValues: {
|
||||
targetHost: store.install?.targetHost,
|
||||
port: store.install?.port,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,7 +76,11 @@ const ConfigureAddress = () => {
|
||||
event,
|
||||
) => {
|
||||
console.log("targetHost set", values);
|
||||
set("install", (s) => ({ ...s, targetHost: values.targetHost }));
|
||||
set("install", (s) => ({
|
||||
...s,
|
||||
targetHost: values.targetHost,
|
||||
port: values.port,
|
||||
}));
|
||||
|
||||
// Here you would typically trigger the ISO creation process
|
||||
stepSignal.next();
|
||||
@@ -81,10 +92,18 @@ const ConfigureAddress = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const portValue = getValue(formStore, "port");
|
||||
const port = portValue ? parseInt(portValue, 10) : undefined;
|
||||
|
||||
setLoading(true);
|
||||
const call = client.fetch("check_machine_ssh_login", {
|
||||
remote: {
|
||||
address,
|
||||
...(port && { port }),
|
||||
ssh_options: {
|
||||
StrictHostKeyChecking: "no",
|
||||
UserKnownHostsFile: "/dev/null",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await call.result;
|
||||
@@ -121,6 +140,25 @@ const ConfigureAddress = () => {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="port">
|
||||
{(field, props) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
label="SSH Port"
|
||||
description="SSH port (default: 22)"
|
||||
value={field.value}
|
||||
orientation="horizontal"
|
||||
validationState={
|
||||
getError(formStore, "port") ? "invalid" : "valid"
|
||||
}
|
||||
input={{
|
||||
...props,
|
||||
placeholder: "22",
|
||||
type: "number",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
</div>
|
||||
}
|
||||
@@ -166,23 +204,41 @@ const CheckHardware = () => {
|
||||
|
||||
const client = useApiClient();
|
||||
|
||||
const [updatingHardwareReport, setUpdatingHardwareReport] =
|
||||
createSignal(false);
|
||||
|
||||
const handleUpdateSummary = async () => {
|
||||
// TODO: Debounce
|
||||
const call = client.fetch("run_machine_hardware_info", {
|
||||
target_host: {
|
||||
address: store.install.targetHost,
|
||||
},
|
||||
opts: {
|
||||
machine: {
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
setUpdatingHardwareReport(true);
|
||||
|
||||
const port = store.install.port
|
||||
? parseInt(store.install.port, 10)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
// TODO: Debounce
|
||||
const call = client.fetch("run_machine_hardware_info", {
|
||||
target_host: {
|
||||
address: store.install.targetHost,
|
||||
...(port && { port }),
|
||||
ssh_options: {
|
||||
StrictHostKeyChecking: "no",
|
||||
UserKnownHostsFile: "/dev/null",
|
||||
},
|
||||
name: store.install.machineName,
|
||||
},
|
||||
},
|
||||
});
|
||||
await call.result;
|
||||
hardwareQuery.refetch();
|
||||
opts: {
|
||||
machine: {
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
name: store.install.machineName,
|
||||
},
|
||||
},
|
||||
});
|
||||
await call.result;
|
||||
await hardwareQuery.refetch();
|
||||
} finally {
|
||||
setUpdatingHardwareReport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reportExists = () => hardwareQuery?.data?.hardware_config !== "none";
|
||||
@@ -197,12 +253,12 @@ const CheckHardware = () => {
|
||||
Hardware Report
|
||||
</Typography>
|
||||
<Button
|
||||
disabled={hardwareQuery.isLoading}
|
||||
disabled={hardwareQuery.isLoading || updatingHardwareReport()}
|
||||
hierarchy="secondary"
|
||||
startIcon="Report"
|
||||
onClick={handleUpdateSummary}
|
||||
class="flex gap-3"
|
||||
loading={hardwareQuery.isFetching}
|
||||
loading={hardwareQuery.isFetching || updatingHardwareReport()}
|
||||
>
|
||||
Update hardware report
|
||||
</Button>
|
||||
@@ -574,6 +630,10 @@ const InstallSummary = () => {
|
||||
}));
|
||||
await runGenerators.result; // Wait for the generators to run
|
||||
|
||||
const port = store.install.port
|
||||
? parseInt(store.install.port, 10)
|
||||
: undefined;
|
||||
|
||||
const runInstall = client.fetch("run_machine_install", {
|
||||
opts: {
|
||||
machine: {
|
||||
@@ -585,6 +645,11 @@ const InstallSummary = () => {
|
||||
},
|
||||
target_host: {
|
||||
address: store.install.targetHost,
|
||||
...(port && { port }),
|
||||
ssh_options: {
|
||||
StrictHostKeyChecking: "no",
|
||||
UserKnownHostsFile: "/dev/null",
|
||||
},
|
||||
},
|
||||
});
|
||||
set("install", (s) => ({
|
||||
@@ -609,6 +674,14 @@ const InstallSummary = () => {
|
||||
<Orienter orientation="horizontal">
|
||||
<Display label="Address" value={store.install.targetHost} />
|
||||
</Orienter>
|
||||
{store.install.port && (
|
||||
<>
|
||||
<Divider orientation="horizontal" />
|
||||
<Orienter orientation="horizontal">
|
||||
<Display label="SSH Port" value={store.install.port} />
|
||||
</Orienter>
|
||||
</>
|
||||
)}
|
||||
</Fieldset>
|
||||
<Fieldset legend="Disk">
|
||||
<Orienter orientation="horizontal">
|
||||
@@ -658,13 +731,13 @@ const InstallProgress = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="relative flex size-full flex-col items-center justify-center bg-inv-4">
|
||||
<div class="relative flex size-full flex-col items-center justify-end bg-inv-4">
|
||||
<img
|
||||
src="/logos/usb-stick-min.png"
|
||||
alt="usb logo"
|
||||
class="absolute top-2 z-0"
|
||||
/>
|
||||
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 fg-inv-1">
|
||||
<Typography
|
||||
hierarchy="title"
|
||||
size="default"
|
||||
@@ -796,10 +869,12 @@ export const installSteps = [
|
||||
id: "install:progress",
|
||||
content: InstallProgress,
|
||||
isSplash: true,
|
||||
class: "max-w-[30rem] h-[18rem]",
|
||||
},
|
||||
{
|
||||
id: "install:done",
|
||||
content: InstallDone,
|
||||
isSplash: true,
|
||||
class: "max-w-[30rem] h-[18rem]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.content {
|
||||
@apply px-3 flex flex-col gap-5 py-6;
|
||||
border: 1px solid #2e4a4b;
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
theme(colors.bg.inv.2) 0%,
|
||||
theme(colors.bg.inv.3) 100%
|
||||
);
|
||||
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #2e4a4b;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply py-3 px-4 flex justify-end w-full;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border-top: 1px solid #2e4a4b;
|
||||
}
|
||||
|
||||
.backgroundAlt {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme(colors.bg.inv.3) 0%,
|
||||
theme(colors.bg.inv.4) 100%
|
||||
);
|
||||
}
|
||||
178
pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx
Normal file
178
pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { ServiceWorkflow } from "./Service";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
MemoryRouter,
|
||||
RouteDefinition,
|
||||
} from "@solidjs/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
|
||||
import {
|
||||
ApiCall,
|
||||
OperationNames,
|
||||
OperationResponse,
|
||||
SuccessQuery,
|
||||
} from "@/src/hooks/api";
|
||||
|
||||
type ResultDataMap = {
|
||||
[K in OperationNames]: SuccessQuery<K>["data"];
|
||||
};
|
||||
|
||||
const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
name: K,
|
||||
_args: unknown,
|
||||
): ApiCall<K> => {
|
||||
// TODO: Make this configurable for every story
|
||||
const resultData: Partial<ResultDataMap> = {
|
||||
list_service_modules: [
|
||||
{
|
||||
module: { name: "Borgbackup", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Borgbackup",
|
||||
description: "This is module A",
|
||||
},
|
||||
roles: {
|
||||
client: null,
|
||||
server: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Zerotier", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Zerotier",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
peer: null,
|
||||
moon: null,
|
||||
controller: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Admin", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Admin",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Garage", input: "lo-l" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Garage",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
list_machines: {
|
||||
jon: {
|
||||
name: "jon",
|
||||
tags: ["all", "nixos", "tag1"],
|
||||
},
|
||||
sara: {
|
||||
name: "sara",
|
||||
tags: ["all", "darwin", "tag2"],
|
||||
},
|
||||
kyra: {
|
||||
name: "kyra",
|
||||
tags: ["all", "darwin", "tag2"],
|
||||
},
|
||||
leila: {
|
||||
name: "leila",
|
||||
tags: ["all", "darwin", "tag2"],
|
||||
},
|
||||
},
|
||||
list_tags: {
|
||||
options: ["desktop", "server", "full", "only", "streaming", "backup"],
|
||||
special: ["all", "nixos", "darwin"],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
uuid: "mock",
|
||||
cancel: () => Promise.resolve(),
|
||||
result: new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
op_key: "1",
|
||||
status: "success",
|
||||
data: resultData[name],
|
||||
} as OperationResponse<K>);
|
||||
}, 1500);
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ServiceWorkflow> = {
|
||||
title: "workflows/service",
|
||||
component: ServiceWorkflow,
|
||||
decorators: [
|
||||
(Story: StoryObj, context: StoryContext) => {
|
||||
const Routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/clans/:clanURI",
|
||||
component: () => (
|
||||
<div>
|
||||
<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 ServiceWorkflow>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const SelectRoleMembers: Story = {
|
||||
render: () => (
|
||||
<ServiceWorkflow
|
||||
handleSubmit={(instance) => {
|
||||
console.log("Submitted instance:", instance);
|
||||
}}
|
||||
initialStep="select:members"
|
||||
initialStore={{
|
||||
currentRole: "peer",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
614
pkgs/clan-app/ui/src/workflows/Service/Service.tsx
Normal file
614
pkgs/clan-app/ui/src/workflows/Service/Service.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
import {
|
||||
createStepper,
|
||||
getStepStore,
|
||||
StepperProvider,
|
||||
useStepper,
|
||||
} from "@/src/hooks/stepper";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
import {
|
||||
MachinesQuery,
|
||||
ServiceModules,
|
||||
TagsQuery,
|
||||
useMachinesQuery,
|
||||
useServiceInstances,
|
||||
useServiceModules,
|
||||
useTags,
|
||||
} from "@/src/hooks/queries";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
JSX,
|
||||
Show,
|
||||
on,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { Search } from "@/src/components/Search/Search";
|
||||
import Icon from "@/src/components/Icon/Icon";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { TagSelect } from "@/src/components/Search/TagSelect";
|
||||
import { Tag } from "@/src/components/Tag/Tag";
|
||||
import { createForm, FieldValues } from "@modular-forms/solid";
|
||||
import styles from "./Service.module.css";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import cx from "classnames";
|
||||
import { BackButton } from "../Steps";
|
||||
import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
|
||||
import { useMachineClick } from "@/src/scene/cubes";
|
||||
import {
|
||||
clearAllHighlights,
|
||||
highlightGroups,
|
||||
setHighlightGroups,
|
||||
} from "@/src/scene/highlightStore";
|
||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||
|
||||
type ModuleItem = ServiceModules[number];
|
||||
|
||||
interface Module {
|
||||
value: string;
|
||||
input?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
raw: ModuleItem;
|
||||
instances: string[];
|
||||
}
|
||||
|
||||
const SelectService = () => {
|
||||
const clanURI = useClanURI();
|
||||
const stepper = useStepper<ServiceSteps>();
|
||||
|
||||
const serviceModulesQuery = useServiceModules(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
|
||||
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
|
||||
createEffect(() => {
|
||||
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
||||
setModuleOptions(
|
||||
serviceModulesQuery.data.map((m) => ({
|
||||
value: `${m.module.name}:${m.module.input}`,
|
||||
label: m.module.name,
|
||||
description: m.info.manifest.description,
|
||||
input: m.module.input,
|
||||
raw: m,
|
||||
// TODO: include the instances that use this module
|
||||
instances: Object.entries(serviceInstancesQuery.data)
|
||||
.filter(
|
||||
([name, i]) =>
|
||||
i.module?.name === m.module.name &&
|
||||
(!i.module?.input || i.module?.input === m.module.input),
|
||||
)
|
||||
.map(([name, _]) => name),
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||
|
||||
return (
|
||||
<Search<Module>
|
||||
loading={serviceModulesQuery.isLoading}
|
||||
height="13rem"
|
||||
onChange={(module) => {
|
||||
if (!module) return;
|
||||
|
||||
set("module", {
|
||||
name: module.raw.module.name,
|
||||
input: module.raw.module.input,
|
||||
raw: module.raw,
|
||||
});
|
||||
// TODO: Ideally we need to ask
|
||||
// - create new
|
||||
// - update existing (and select which one)
|
||||
|
||||
// For now:
|
||||
// Create a new instance, if there are no instances yet
|
||||
// Update the first instance, if there is one
|
||||
if (module.instances.length === 0) {
|
||||
set("action", "create");
|
||||
} else {
|
||||
if (!serviceInstancesQuery.data) return;
|
||||
if (!machinesQuery.data) return;
|
||||
set("action", "update");
|
||||
|
||||
const instanceName = module.instances[0];
|
||||
const instance = serviceInstancesQuery.data[instanceName];
|
||||
console.log("Editing existing instance", module);
|
||||
|
||||
for (const role of Object.keys(instance.roles || {})) {
|
||||
const tags = Object.keys(instance.roles?.[role].tags || {});
|
||||
const machines = Object.keys(instance.roles?.[role].machines || {});
|
||||
|
||||
const machineTags = machines.map((m) => ({
|
||||
value: "m_" + m,
|
||||
label: m,
|
||||
type: "machine" as const,
|
||||
}));
|
||||
const tagsTags = tags.map((t) => {
|
||||
return {
|
||||
value: "t_" + t,
|
||||
label: t,
|
||||
type: "tag" as const,
|
||||
members: Object.entries(machinesQuery.data || {})
|
||||
.filter(([_, m]) => m.tags?.includes(t))
|
||||
.map(([k]) => k),
|
||||
};
|
||||
});
|
||||
console.log("Members for role", role, [
|
||||
...machineTags,
|
||||
...tagsTags,
|
||||
]);
|
||||
if (!store.roles) {
|
||||
set("roles", {});
|
||||
}
|
||||
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
set("roles", role, roleMembers);
|
||||
console.log("set", store.roles);
|
||||
}
|
||||
// Initialize the roles with the existing members
|
||||
}
|
||||
|
||||
stepper.next();
|
||||
}}
|
||||
options={moduleOptions()}
|
||||
renderItem={(item, opts) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||
<Icon icon="Code" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<Combobox.ItemLabel class="flex gap-1.5">
|
||||
<Show when={item.instances.length > 0}>
|
||||
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
||||
<Typography hierarchy="label" weight="bold" size="xxs">
|
||||
Added
|
||||
</Typography>
|
||||
</div>
|
||||
</Show>
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Combobox.ItemLabel>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xxs"
|
||||
weight="normal"
|
||||
color="quaternary"
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span class="inline-block max-w-32 truncate align-middle">
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-8 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
||||
createMemo<TagType[]>(() => {
|
||||
const tags = tagsQuery.data;
|
||||
const machines = machinesQuery.data;
|
||||
if (!tags || !machines) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const machineOptions = Object.keys(machines).map((m) => ({
|
||||
label: m,
|
||||
value: "m_" + m,
|
||||
type: "machine" as const,
|
||||
}));
|
||||
|
||||
const tagOptions = [...tags.options, ...tags.special].map((tag) => ({
|
||||
type: "tag" as const,
|
||||
label: tag,
|
||||
value: "t_" + tag,
|
||||
members: Object.entries(machines)
|
||||
.filter(([_, v]) => v.tags?.includes(tag))
|
||||
.map(([k]) => k),
|
||||
}));
|
||||
|
||||
return [...machineOptions, ...tagOptions].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
});
|
||||
|
||||
interface RolesForm extends FieldValues {
|
||||
roles: Record<string, string[]>;
|
||||
instanceName: string;
|
||||
}
|
||||
const ConfigureService = () => {
|
||||
const stepper = useStepper<ServiceSteps>();
|
||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
||||
initialValues: {
|
||||
// Default to the module name, until we support multiple instances
|
||||
instanceName: store.module.name,
|
||||
},
|
||||
});
|
||||
|
||||
const machinesQuery = useMachinesQuery(useClanURI());
|
||||
const tagsQuery = useTags(useClanURI());
|
||||
|
||||
const options = useOptions(tagsQuery, machinesQuery);
|
||||
|
||||
const handleSubmit = (values: RolesForm) => {
|
||||
const roles: Record<string, RoleType> = Object.fromEntries(
|
||||
Object.entries(store.roles).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
machines: Object.fromEntries(
|
||||
value.filter((v) => v.type === "machine").map((v) => [v.label, {}]),
|
||||
),
|
||||
tags: Object.fromEntries(
|
||||
value.filter((v) => v.type === "tag").map((v) => [v.label, {}]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
store.handleSubmit(
|
||||
{
|
||||
name: values.instanceName,
|
||||
module: {
|
||||
name: store.module.name,
|
||||
input: store.module.input,
|
||||
},
|
||||
roles,
|
||||
},
|
||||
store.action,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div class={cx(styles.header, styles.backgroundAlt)}>
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<Icon icon="Services" size={36} inverted />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
{store.module.name}
|
||||
</Typography>
|
||||
<Field name="instanceName">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
size="s"
|
||||
inverted
|
||||
required
|
||||
readOnly={true}
|
||||
orientation="horizontal"
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
icon="Close"
|
||||
color="primary"
|
||||
ghost
|
||||
size="s"
|
||||
class="ml-auto"
|
||||
onClick={store.close}
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.content}>
|
||||
<For each={Object.keys(store.module.raw?.info.roles || {})}>
|
||||
{(role) => {
|
||||
const values = store.roles?.[role] || [];
|
||||
return (
|
||||
<TagSelect<TagType>
|
||||
label={role}
|
||||
renderItem={(item: TagType) => (
|
||||
<Tag
|
||||
inverted
|
||||
icon={(tag) => (
|
||||
<Icon
|
||||
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||
size="0.5rem"
|
||||
inverted={tag.inverted}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Tag>
|
||||
)}
|
||||
values={values}
|
||||
options={options()}
|
||||
onClick={() => {
|
||||
set("currentRole", role);
|
||||
stepper.next();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||
<BackButton ghost hierarchy="primary" class="mr-auto" />
|
||||
|
||||
<Button hierarchy="secondary" type="submit">
|
||||
<Show when={store.action === "create"}>Add Service</Show>
|
||||
<Show when={store.action === "update"}>Save Changes</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
type TagType =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
type: "machine";
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
type: "tag";
|
||||
members: string[];
|
||||
};
|
||||
|
||||
const ConfigureRole = () => {
|
||||
const stepper = useStepper<ServiceSteps>();
|
||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||
|
||||
const [members, setMembers] = createSignal<TagType[]>(
|
||||
store.roles?.[store.currentRole || ""] || [],
|
||||
);
|
||||
|
||||
const lastClickedMachine = useMachineClick();
|
||||
|
||||
createEffect(() => {
|
||||
console.log("Current role", store.currentRole, members());
|
||||
clearAllHighlights();
|
||||
setHighlightGroups({
|
||||
[store.currentRole as string]: new Set(
|
||||
members().flatMap((m) => {
|
||||
if (m.type === "machine") return m.label;
|
||||
|
||||
return m.members;
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
console.log("now", highlightGroups);
|
||||
});
|
||||
onMount(() => setHighlightGroups(() => ({})));
|
||||
|
||||
createEffect(
|
||||
on(lastClickedMachine, (machine) => {
|
||||
// const machine = lastClickedMachine();
|
||||
const currentMembers = members();
|
||||
console.log("Clicked machine", machine, currentMembers);
|
||||
if (!machine) return;
|
||||
const machineTagName = "m_" + machine;
|
||||
|
||||
const existing = currentMembers.find((m) => m.value === machineTagName);
|
||||
if (existing) {
|
||||
// Remove
|
||||
setMembers(currentMembers.filter((m) => m.value !== machineTagName));
|
||||
} else {
|
||||
// Add
|
||||
setMembers([
|
||||
...currentMembers,
|
||||
{ value: machineTagName, label: machine, type: "machine" },
|
||||
]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const machinesQuery = useMachinesQuery(useClanURI());
|
||||
const tagsQuery = useTags(useClanURI());
|
||||
|
||||
const options = useOptions(tagsQuery, machinesQuery);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!store.currentRole) return;
|
||||
|
||||
if (!store.roles) {
|
||||
set("roles", {});
|
||||
}
|
||||
set("roles", (r) => ({ ...r, [store.currentRole as string]: members() }));
|
||||
stepper.setActiveStep("view:members");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={() => handleSubmit()}>
|
||||
<div class={cx(styles.backgroundAlt, "rounded-md")}>
|
||||
<div class="flex w-full flex-col ">
|
||||
<SearchMultiple<TagType>
|
||||
values={members()}
|
||||
options={options()}
|
||||
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
|
||||
headerChildren={
|
||||
<div class="flex w-full gap-2.5">
|
||||
<BackButton
|
||||
ghost
|
||||
size="xs"
|
||||
hierarchy="primary"
|
||||
// onClick={() => clearAllHighlights()}
|
||||
/>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
class="capitalize"
|
||||
>
|
||||
Select {store.currentRole}
|
||||
</Typography>
|
||||
</div>
|
||||
}
|
||||
placeholder={"Search for Machine or Tags"}
|
||||
renderItem={(item, opts) => (
|
||||
<div class={cx("flex w-full items-center gap-2 px-3 py-2")}>
|
||||
<Combobox.ItemIndicator>
|
||||
<Show when={opts.selected} fallback={<Icon icon="Code" />}>
|
||||
<Icon icon="Checkmark" color="primary" inverted />
|
||||
</Show>
|
||||
</Combobox.ItemIndicator>
|
||||
<Combobox.ItemLabel class="flex items-center gap-2">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Show when={item.type === "tag" && item}>
|
||||
{(tag) => (
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted
|
||||
color="secondary"
|
||||
tag="div"
|
||||
>
|
||||
{tag().members.length}
|
||||
</Typography>
|
||||
)}
|
||||
</Show>
|
||||
</Combobox.ItemLabel>
|
||||
<Icon
|
||||
class="ml-auto"
|
||||
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||
color="quaternary"
|
||||
inverted
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
height="20rem"
|
||||
virtualizerOptions={{
|
||||
estimateSize: () => 38,
|
||||
}}
|
||||
onChange={(selection) => {
|
||||
setMembers(selection);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||
<Button hierarchy="secondary" type="submit">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "select:service",
|
||||
content: SelectService,
|
||||
},
|
||||
{
|
||||
id: "view:members",
|
||||
content: ConfigureService,
|
||||
},
|
||||
{
|
||||
id: "select:members",
|
||||
content: ConfigureRole,
|
||||
},
|
||||
{ id: "settings", content: () => <div>Adjust settings here.</div> },
|
||||
] as const;
|
||||
|
||||
export type ServiceSteps = typeof steps;
|
||||
|
||||
// TODO: Ideally we would impot this from a backend model package
|
||||
export interface InventoryInstance {
|
||||
name: string;
|
||||
module: {
|
||||
name: string;
|
||||
input?: string;
|
||||
};
|
||||
roles: Record<string, RoleType>;
|
||||
}
|
||||
|
||||
interface RoleType {
|
||||
machines: Record<string, { settings?: unknown }>;
|
||||
tags: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServiceStoreType {
|
||||
module: {
|
||||
name: string;
|
||||
input: string;
|
||||
raw?: ModuleItem;
|
||||
};
|
||||
roles: Record<string, TagType[]>;
|
||||
currentRole?: string;
|
||||
close: () => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
action: "create" | "update";
|
||||
}
|
||||
|
||||
export type SubmitServiceHandler = (
|
||||
values: InventoryInstance,
|
||||
action: "create" | "update",
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface ServiceWorkflowProps {
|
||||
initialStep?: ServiceSteps[number]["id"];
|
||||
initialStore?: Partial<ServiceStoreType>;
|
||||
onClose?: () => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||
const stepper = createStepper(
|
||||
{ steps },
|
||||
{
|
||||
initialStep: props.initialStep || "select:service",
|
||||
initialStoreData: {
|
||||
...props.initialStore,
|
||||
close: () => props.onClose?.(),
|
||||
handleSubmit: props.handleSubmit,
|
||||
} satisfies Partial<ServiceStoreType>,
|
||||
},
|
||||
);
|
||||
createEffect(() => {
|
||||
if (stepper.currentStep().id !== "select:members") {
|
||||
clearAllHighlights();
|
||||
}
|
||||
});
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
useClickOutside(
|
||||
() => ref,
|
||||
() => {
|
||||
if (stepper.currentStep().id === "select:service") props.onClose?.();
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={(e) => (ref = e)}
|
||||
id="add-service"
|
||||
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
||||
{...props.rootProps}
|
||||
>
|
||||
<StepperProvider stepper={stepper}>
|
||||
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
||||
</StepperProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
pkgs/clan-app/ui/src/workflows/Steps.module.css
Normal file
7
pkgs/clan-app/ui/src/workflows/Steps.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.step {
|
||||
@apply relative flex size-full flex-col justify-between gap-8;
|
||||
|
||||
.footer {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { JSX } from "solid-js";
|
||||
import { useStepper } from "../hooks/stepper";
|
||||
import { Button, ButtonProps } from "../components/Button/Button";
|
||||
import { InstallSteps } from "./Install/install";
|
||||
import styles from "./Steps.module.css";
|
||||
|
||||
interface StepLayoutProps {
|
||||
body: JSX.Element;
|
||||
@@ -9,7 +10,7 @@ interface StepLayoutProps {
|
||||
}
|
||||
export const StepLayout = (props: StepLayoutProps) => {
|
||||
return (
|
||||
<div class="flex size-full grow flex-col justify-between gap-6">
|
||||
<div class={styles.step}>
|
||||
{props.body}
|
||||
{props.footer}
|
||||
</div>
|
||||
@@ -34,7 +35,8 @@ export const NextButton = (props: NextButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const BackButton = () => {
|
||||
type BackButtonProps = ButtonProps & {};
|
||||
export const BackButton = (props: BackButtonProps) => {
|
||||
const stepSignal = useStepper<InstallSteps>();
|
||||
return (
|
||||
<Button
|
||||
@@ -44,6 +46,7 @@ export const BackButton = () => {
|
||||
onClick={() => {
|
||||
stepSignal.previous();
|
||||
}}
|
||||
{...props}
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
@@ -63,7 +66,7 @@ interface StepFooterProps {
|
||||
export const StepFooter = (props: StepFooterProps) => {
|
||||
const stepper = useStepper<InstallSteps>();
|
||||
return (
|
||||
<div class="flex justify-between py-4">
|
||||
<div class={styles.footer}>
|
||||
<BackButton />
|
||||
<NextButton type="button" onClick={() => stepper.next()}>
|
||||
{props.nextText || undefined}
|
||||
|
||||
@@ -8,7 +8,8 @@ from clan_lib.api import load_in_all_api_functions
|
||||
def main() -> None:
|
||||
load_in_all_api_functions()
|
||||
|
||||
from clan_lib.api import API
|
||||
# import lazily since we otherwise we do not have all api functions loaded according to Qubasa
|
||||
from clan_lib.api import API # noqa: PLC0415
|
||||
|
||||
schema = API.to_json_schema()
|
||||
print(f"""{json.dumps(schema, indent=2)}""")
|
||||
|
||||
@@ -539,11 +539,11 @@ def main() -> None:
|
||||
|
||||
try:
|
||||
args.func(args)
|
||||
except ClanError as e:
|
||||
except ClanError:
|
||||
if debug:
|
||||
log.exception("Exited with error")
|
||||
else:
|
||||
log.error("%s", e)
|
||||
log.exception("Exited with error")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt as ex:
|
||||
log.warning("Interrupted by user", exc_info=ex)
|
||||
|
||||
@@ -4,13 +4,20 @@ import json
|
||||
import subprocess
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from clan_lib.cmd import run
|
||||
from clan_lib.dirs import get_clan_flake_toplevel_or_env
|
||||
from clan_lib.flake.flake import Flake
|
||||
from clan_lib.nix import nix_eval
|
||||
from clan_lib.persist.inventory_store import InventoryStore
|
||||
from clan_lib.templates import list_templates
|
||||
|
||||
from .secrets.groups import list_groups
|
||||
from .secrets.secrets import list_secrets
|
||||
from .secrets.users import list_users
|
||||
|
||||
"""
|
||||
This module provides dynamic completions.
|
||||
@@ -31,7 +38,6 @@ COMPLETION_TIMEOUT: int = 3
|
||||
def clan_dir(flake: str | None) -> str | None:
|
||||
if flake is not None:
|
||||
return flake
|
||||
from clan_lib.dirs import get_clan_flake_toplevel_or_env
|
||||
|
||||
path_result = get_clan_flake_toplevel_or_env()
|
||||
return str(path_result) if path_result is not None else None
|
||||
@@ -67,8 +73,7 @@ def complete_machines(
|
||||
if thread.is_alive():
|
||||
return iter([])
|
||||
|
||||
machines_dict = dict.fromkeys(machines, "machine")
|
||||
return machines_dict
|
||||
return dict.fromkeys(machines, "machine")
|
||||
|
||||
|
||||
def complete_services_for_machine(
|
||||
@@ -112,8 +117,7 @@ def complete_services_for_machine(
|
||||
if thread.is_alive():
|
||||
return iter([])
|
||||
|
||||
services_dict = dict.fromkeys(services, "service")
|
||||
return services_dict
|
||||
return dict.fromkeys(services, "service")
|
||||
|
||||
|
||||
def complete_backup_providers_for_machine(
|
||||
@@ -156,8 +160,7 @@ def complete_backup_providers_for_machine(
|
||||
if thread.is_alive():
|
||||
return iter([])
|
||||
|
||||
providers_dict = dict.fromkeys(providers, "provider")
|
||||
return providers_dict
|
||||
return dict.fromkeys(providers, "provider")
|
||||
|
||||
|
||||
def complete_state_services_for_machine(
|
||||
@@ -200,8 +203,7 @@ def complete_state_services_for_machine(
|
||||
if thread.is_alive():
|
||||
return iter([])
|
||||
|
||||
providers_dict = dict.fromkeys(providers, "service")
|
||||
return providers_dict
|
||||
return dict.fromkeys(providers, "service")
|
||||
|
||||
|
||||
def complete_secrets(
|
||||
@@ -210,10 +212,6 @@ def complete_secrets(
|
||||
**_kwargs: Any,
|
||||
) -> Iterable[str]:
|
||||
"""Provides completion functionality for clan secrets"""
|
||||
from clan_lib.flake.flake import Flake
|
||||
|
||||
from .secrets.secrets import list_secrets
|
||||
|
||||
flake = (
|
||||
clan_dir_result
|
||||
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
|
||||
@@ -223,8 +221,7 @@ def complete_secrets(
|
||||
|
||||
secrets = list_secrets(Flake(flake).path)
|
||||
|
||||
secrets_dict = dict.fromkeys(secrets, "secret")
|
||||
return secrets_dict
|
||||
return dict.fromkeys(secrets, "secret")
|
||||
|
||||
|
||||
def complete_users(
|
||||
@@ -233,10 +230,6 @@ def complete_users(
|
||||
**_kwargs: Any,
|
||||
) -> Iterable[str]:
|
||||
"""Provides completion functionality for clan users"""
|
||||
from pathlib import Path
|
||||
|
||||
from .secrets.users import list_users
|
||||
|
||||
flake = (
|
||||
clan_dir_result
|
||||
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
|
||||
@@ -246,8 +239,7 @@ def complete_users(
|
||||
|
||||
users = list_users(Path(flake))
|
||||
|
||||
users_dict = dict.fromkeys(users, "user")
|
||||
return users_dict
|
||||
return dict.fromkeys(users, "user")
|
||||
|
||||
|
||||
def complete_groups(
|
||||
@@ -256,10 +248,6 @@ def complete_groups(
|
||||
**_kwargs: Any,
|
||||
) -> Iterable[str]:
|
||||
"""Provides completion functionality for clan groups"""
|
||||
from pathlib import Path
|
||||
|
||||
from .secrets.groups import list_groups
|
||||
|
||||
flake = (
|
||||
clan_dir_result
|
||||
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
|
||||
@@ -270,8 +258,7 @@ def complete_groups(
|
||||
groups_list = list_groups(Path(flake))
|
||||
groups = [group.name for group in groups_list]
|
||||
|
||||
groups_dict = dict.fromkeys(groups, "group")
|
||||
return groups_dict
|
||||
return dict.fromkeys(groups, "group")
|
||||
|
||||
|
||||
def complete_templates_disko(
|
||||
@@ -280,8 +267,6 @@ def complete_templates_disko(
|
||||
**_kwargs: Any,
|
||||
) -> Iterable[str]:
|
||||
"""Provides completion functionality for disko templates"""
|
||||
from clan_lib.templates import list_templates
|
||||
|
||||
flake = (
|
||||
clan_dir_result
|
||||
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
|
||||
@@ -293,8 +278,7 @@ def complete_templates_disko(
|
||||
disko_template_list = list_all_templates.builtins.get("disko")
|
||||
if disko_template_list:
|
||||
disko_templates = list(disko_template_list)
|
||||
disko_dict = dict.fromkeys(disko_templates, "disko")
|
||||
return disko_dict
|
||||
return dict.fromkeys(disko_templates, "disko")
|
||||
return []
|
||||
|
||||
|
||||
@@ -304,8 +288,6 @@ def complete_templates_clan(
|
||||
**_kwargs: Any,
|
||||
) -> Iterable[str]:
|
||||
"""Provides completion functionality for clan templates"""
|
||||
from clan_lib.templates import list_templates
|
||||
|
||||
flake = (
|
||||
clan_dir_result
|
||||
if (clan_dir_result := clan_dir(getattr(parsed_args, "flake", None)))
|
||||
@@ -317,8 +299,7 @@ def complete_templates_clan(
|
||||
clan_template_list = list_all_templates.builtins.get("clan")
|
||||
if clan_template_list:
|
||||
clan_templates = list(clan_template_list)
|
||||
clan_dict = dict.fromkeys(clan_templates, "clan")
|
||||
return clan_dict
|
||||
return dict.fromkeys(clan_templates, "clan")
|
||||
return []
|
||||
|
||||
|
||||
@@ -331,8 +312,6 @@ def complete_vars_for_machine(
|
||||
Only completes vars that already exist in the vars directory on disk.
|
||||
This is fast as it only scans the filesystem without any evaluation.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
machine_name = getattr(parsed_args, "machine", None)
|
||||
if not machine_name:
|
||||
return []
|
||||
@@ -362,8 +341,7 @@ def complete_vars_for_machine(
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
vars_dict = dict.fromkeys(vars_list, "var")
|
||||
return vars_dict
|
||||
return dict.fromkeys(vars_list, "var")
|
||||
|
||||
|
||||
def complete_target_host(
|
||||
@@ -404,8 +382,7 @@ def complete_target_host(
|
||||
if thread.is_alive():
|
||||
return iter([])
|
||||
|
||||
providers_dict = dict.fromkeys(target_hosts, "target_host")
|
||||
return providers_dict
|
||||
return dict.fromkeys(target_hosts, "target_host")
|
||||
|
||||
|
||||
def complete_tags(
|
||||
@@ -474,8 +451,7 @@ def complete_tags(
|
||||
if any(thread.is_alive() for thread in threads):
|
||||
return iter([])
|
||||
|
||||
providers_dict = dict.fromkeys(tags, "tag")
|
||||
return providers_dict
|
||||
return dict.fromkeys(tags, "tag")
|
||||
|
||||
|
||||
def add_dynamic_completer(
|
||||
|
||||
@@ -13,24 +13,24 @@ def check_secrets(machine: Machine, service: None | str = None) -> bool:
|
||||
missing_secret_facts = []
|
||||
missing_public_facts = []
|
||||
services = [service] if service else list(machine.facts_data.keys())
|
||||
for service in services:
|
||||
for secret_fact in machine.facts_data[service]["secret"]:
|
||||
for svc in services:
|
||||
for secret_fact in machine.facts_data[svc]["secret"]:
|
||||
if isinstance(secret_fact, str):
|
||||
secret_name = secret_fact
|
||||
else:
|
||||
secret_name = secret_fact["name"]
|
||||
if not machine.secret_facts_store.exists(service, secret_name):
|
||||
if not machine.secret_facts_store.exists(svc, secret_name):
|
||||
machine.info(
|
||||
f"Secret fact '{secret_fact}' for service '{service}' is missing.",
|
||||
f"Secret fact '{secret_fact}' for service '{svc}' is missing.",
|
||||
)
|
||||
missing_secret_facts.append((service, secret_name))
|
||||
missing_secret_facts.append((svc, secret_name))
|
||||
|
||||
for public_fact in machine.facts_data[service]["public"]:
|
||||
if not machine.public_facts_store.exists(service, public_fact):
|
||||
for public_fact in machine.facts_data[svc]["public"]:
|
||||
if not machine.public_facts_store.exists(svc, public_fact):
|
||||
machine.info(
|
||||
f"Public fact '{public_fact}' for service '{service}' is missing.",
|
||||
f"Public fact '{public_fact}' for service '{svc}' is missing.",
|
||||
)
|
||||
missing_public_facts.append((service, public_fact))
|
||||
missing_public_facts.append((svc, public_fact))
|
||||
|
||||
machine.debug(f"missing_secret_facts: {missing_secret_facts}")
|
||||
machine.debug(f"missing_public_facts: {missing_public_facts}")
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from clan_lib import bwrap
|
||||
from clan_lib.cmd import RunOpts, run
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.flake import require_flake
|
||||
@@ -104,7 +105,6 @@ def generate_service_facts(
|
||||
machine.facts_data[service]["generator"]["prompt"],
|
||||
)
|
||||
env["prompt_value"] = prompt_value
|
||||
from clan_lib import bwrap
|
||||
|
||||
if sys.platform == "linux" and bwrap.bubblewrap_works():
|
||||
cmd = bubblewrap_cmd(generator, facts_dir, secrets_dir)
|
||||
@@ -178,10 +178,10 @@ def _generate_facts_for_machine(
|
||||
else:
|
||||
machine_service_facts = machine.facts_data
|
||||
|
||||
for service in machine_service_facts:
|
||||
for svc in machine_service_facts:
|
||||
machine_updated |= generate_service_facts(
|
||||
machine=machine,
|
||||
service=service,
|
||||
service=svc,
|
||||
regenerate=regenerate,
|
||||
secret_facts_store=machine.secret_facts_store,
|
||||
public_facts_store=machine.public_facts_store,
|
||||
|
||||
@@ -124,10 +124,8 @@ class SecretStore(SecretStoreBase):
|
||||
os.umask(0o077)
|
||||
for service in self.machine.facts_data:
|
||||
for secret in self.machine.facts_data[service]["secret"]:
|
||||
if isinstance(secret, dict):
|
||||
secret_name = secret["name"]
|
||||
else:
|
||||
# TODO: drop old format soon
|
||||
secret_name = secret
|
||||
secret_name = (
|
||||
secret["name"] if isinstance(secret, dict) else secret
|
||||
) # TODO: drop old format soon
|
||||
(output_dir / secret_name).write_bytes(self.get(service, secret_name))
|
||||
(output_dir / ".pass_info").write_bytes(self.generate_hash())
|
||||
|
||||
@@ -14,6 +14,9 @@ from clan_cli.completions import add_dynamic_completer, complete_machines
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Constants for disk validation
|
||||
EXPECTED_DISK_VALUES = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlashOptions:
|
||||
@@ -44,7 +47,7 @@ class AppendDiskAction(argparse.Action):
|
||||
if not (
|
||||
isinstance(values, Sequence)
|
||||
and not isinstance(values, str)
|
||||
and len(values) == 2
|
||||
and len(values) == EXPECTED_DISK_VALUES
|
||||
):
|
||||
msg = "Two values must be provided for a 'disk'"
|
||||
raise ValueError(msg)
|
||||
@@ -86,7 +89,7 @@ def flash_command(args: argparse.Namespace) -> None:
|
||||
|
||||
run_machine_flash(
|
||||
machine=machine,
|
||||
mode=opts.mode, # type: ignore
|
||||
mode=opts.mode, # type: ignore[arg-type]
|
||||
disks=opts.disks,
|
||||
system_config=opts.system_config,
|
||||
dry_run=opts.dry_run,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import sys
|
||||
|
||||
|
||||
# Implementation of OSC8
|
||||
def hyperlink(text: str, url: str) -> str:
|
||||
"""Generate OSC8 escape sequence for hyperlinks.
|
||||
@@ -20,11 +23,7 @@ def hyperlink_same_text_and_url(url: str) -> str:
|
||||
|
||||
|
||||
def help_hyperlink(description: str, url: str) -> str:
|
||||
import sys
|
||||
|
||||
"""
|
||||
Keep the description and the link the same to support legacy terminals.
|
||||
"""
|
||||
"""Keep the description and the link the same to support legacy terminals."""
|
||||
if sys.argv[0].__contains__("docs.py"):
|
||||
return docs_hyperlink(description, url)
|
||||
|
||||
|
||||
@@ -92,16 +92,21 @@ Examples:
|
||||
"update-hardware-config",
|
||||
help="Generate hardware specifics for a machine",
|
||||
description="""
|
||||
Generates hardware specifics for a machine. Such as the host platform, available kernel modules, etc.
|
||||
|
||||
This command will use kexec to boot the target into a minimal NixOS environment to gather the hardware information.
|
||||
If you want to manually ssh into the target after this command use `ssh root@<ip> -i ~/.config/clan/nixos-anywhere/keys/id_ed25519`
|
||||
|
||||
|
||||
The target must be a Linux based system reachable via SSH.
|
||||
|
||||
|
||||
""",
|
||||
epilog=(
|
||||
"""
|
||||
Examples:
|
||||
|
||||
$ clan machines update-hardware-config [MACHINE] [TARGET_HOST]
|
||||
Will generate hardware specifics for the the specified `[TARGET_HOST]` and place the result in hardware.nix for the given machine `[MACHINE]`.
|
||||
$ clan machines update-hardware-config [MACHINE] --target-host root@<ip>
|
||||
Will generate the facter.json hardware report for `[TARGET_HOST]` and place the result in facter.json for the given machine `[MACHINE]`.
|
||||
|
||||
For more detailed information, visit: https://docs.clan.lol/guides/getting-started/configure/#machine-configuration
|
||||
|
||||
|
||||
@@ -44,6 +44,18 @@ def update_hardware_config_command(args: argparse.Namespace) -> None:
|
||||
private_key=args.identity_file,
|
||||
)
|
||||
|
||||
if not args.yes:
|
||||
confirm = (
|
||||
input(
|
||||
f"Update hardware configuration for machine '{machine.name}' at '{target_host.target}'? [y/N]: "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if confirm not in ("y", "yes"):
|
||||
log.info("Aborted.")
|
||||
return
|
||||
|
||||
run_machine_hardware_info(opts, target_host)
|
||||
|
||||
|
||||
@@ -56,11 +68,16 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
add_dynamic_completer(machine_parser, complete_machines)
|
||||
parser.add_argument(
|
||||
"target_host",
|
||||
"--target-host",
|
||||
type=str,
|
||||
nargs="?",
|
||||
help="ssh address to install to in the form of user@host:2222",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-y",
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host-key-check",
|
||||
choices=list(get_args(HostKeyCheck)),
|
||||
|
||||
@@ -3,15 +3,18 @@ import re
|
||||
|
||||
VALID_HOSTNAME = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", re.IGNORECASE)
|
||||
|
||||
# Maximum hostname/machine name length as per RFC specifications
|
||||
MAX_HOSTNAME_LENGTH = 63
|
||||
|
||||
|
||||
def validate_hostname(hostname: str) -> bool:
|
||||
if len(hostname) > 63:
|
||||
if len(hostname) > MAX_HOSTNAME_LENGTH:
|
||||
return False
|
||||
return VALID_HOSTNAME.match(hostname) is not None
|
||||
|
||||
|
||||
def machine_name_type(arg_value: str) -> str:
|
||||
if len(arg_value) > 63:
|
||||
if len(arg_value) > MAX_HOSTNAME_LENGTH:
|
||||
msg = "Machine name must be less than 63 characters long"
|
||||
raise argparse.ArgumentTypeError(msg)
|
||||
if not VALID_HOSTNAME.match(arg_value):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.flake import require_flake
|
||||
from clan_lib.network.network import networks_from_flake
|
||||
|
||||
@@ -19,7 +20,8 @@ def list_command(args: argparse.Namespace) -> None:
|
||||
col_network = max(12, *(len(name) for name in networks))
|
||||
col_priority = 8
|
||||
col_module = max(
|
||||
10, *(len(net.module_name.split(".")[-1]) for net in networks.values())
|
||||
10,
|
||||
*(len(net.module_name.split(".")[-1]) for net in networks.values()),
|
||||
)
|
||||
col_running = 8
|
||||
|
||||
@@ -53,7 +55,7 @@ def list_command(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
is_running = network.is_running()
|
||||
running_status = "Yes" if is_running else "No"
|
||||
except Exception:
|
||||
except ClanError:
|
||||
running_status = "Error"
|
||||
|
||||
print(
|
||||
|
||||
@@ -10,6 +10,10 @@ from typing import Any
|
||||
|
||||
# Ensure you have a logger set up for logging exceptions
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Constants for path trimming and profiler configuration
|
||||
MAX_PATH_LEVELS = 4
|
||||
|
||||
explanation = """
|
||||
cProfile Output Columns Explanation:
|
||||
|
||||
@@ -86,8 +90,8 @@ class ProfilerStore:
|
||||
|
||||
def trim_path_to_three_levels(path: str) -> str:
|
||||
parts = path.split(os.path.sep)
|
||||
if len(parts) > 4:
|
||||
return os.path.sep.join(parts[-4:])
|
||||
if len(parts) > MAX_PATH_LEVELS:
|
||||
return os.path.sep.join(parts[-MAX_PATH_LEVELS:])
|
||||
return path
|
||||
|
||||
|
||||
@@ -100,7 +104,6 @@ def profile(func: Callable) -> Callable:
|
||||
"""
|
||||
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
global PROFS
|
||||
profiler = PROFS[func]
|
||||
|
||||
try:
|
||||
|
||||
@@ -6,13 +6,6 @@ from pathlib import Path
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.git import commit_files
|
||||
|
||||
from clan_cli.completions import (
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
complete_machines,
|
||||
complete_secrets,
|
||||
complete_users,
|
||||
)
|
||||
from clan_cli.machines.types import machine_name_type, validate_hostname
|
||||
from clan_cli.secrets.sops import load_age_plugins
|
||||
|
||||
@@ -238,6 +231,11 @@ def remove_machine_command(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def add_group_argument(parser: argparse.ArgumentParser) -> None:
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
)
|
||||
|
||||
group_action = parser.add_argument(
|
||||
"group",
|
||||
help="the name of the secret",
|
||||
@@ -336,6 +334,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machines to add",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(add_machine_action, complete_machines)
|
||||
add_machine_parser.set_defaults(func=add_machine_command)
|
||||
|
||||
@@ -350,6 +353,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machines to remove",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(remove_machine_action, complete_machines)
|
||||
remove_machine_parser.set_defaults(func=remove_machine_command)
|
||||
|
||||
@@ -361,6 +369,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the user to add",
|
||||
type=user_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_users,
|
||||
)
|
||||
|
||||
add_dynamic_completer(add_user_action, complete_users)
|
||||
add_user_parser.set_defaults(func=add_user_command)
|
||||
|
||||
@@ -375,6 +388,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the user to remove",
|
||||
type=user_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_users,
|
||||
)
|
||||
|
||||
add_dynamic_completer(remove_user_action, complete_users)
|
||||
remove_user_parser.set_defaults(func=remove_user_command)
|
||||
|
||||
@@ -389,6 +407,11 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the secret",
|
||||
type=secret_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_secrets,
|
||||
)
|
||||
|
||||
add_dynamic_completer(add_secret_action, complete_secrets)
|
||||
add_secret_parser.set_defaults(func=add_secret_command)
|
||||
|
||||
@@ -403,5 +426,10 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the secret",
|
||||
type=secret_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_secrets,
|
||||
)
|
||||
|
||||
add_dynamic_completer(remove_secret_action, complete_secrets)
|
||||
remove_secret_parser.set_defaults(func=remove_secret_command)
|
||||
|
||||
@@ -7,12 +7,6 @@ from clan_lib.cmd import RunOpts, run
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.nix import nix_shell
|
||||
|
||||
from clan_cli.completions import (
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
complete_machines,
|
||||
complete_users,
|
||||
)
|
||||
from clan_cli.secrets.sops import load_age_plugins
|
||||
|
||||
from .secrets import encrypt_secret, sops_secrets_folder
|
||||
@@ -75,6 +69,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
|
||||
default=[],
|
||||
help="the group to import the secrets to",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
)
|
||||
|
||||
add_dynamic_completer(group_action, complete_groups)
|
||||
machine_action = parser.add_argument(
|
||||
"--machine",
|
||||
@@ -83,6 +82,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
|
||||
default=[],
|
||||
help="the machine to import the secrets to",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(machine_action, complete_machines)
|
||||
user_action = parser.add_argument(
|
||||
"--user",
|
||||
@@ -91,6 +95,11 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
|
||||
default=[],
|
||||
help="the user to import the secrets to",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_users,
|
||||
)
|
||||
|
||||
add_dynamic_completer(user_action, complete_users)
|
||||
parser.add_argument(
|
||||
"--prefix",
|
||||
|
||||
@@ -4,11 +4,6 @@ from pathlib import Path
|
||||
from clan_lib.flake import require_flake
|
||||
from clan_lib.git import commit_files
|
||||
|
||||
from clan_cli.completions import (
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
complete_secrets,
|
||||
)
|
||||
from clan_cli.machines.types import machine_name_type, validate_hostname
|
||||
|
||||
from . import secrets, sops
|
||||
@@ -177,6 +172,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machine",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(add_machine_action, complete_machines)
|
||||
add_parser.add_argument(
|
||||
"key",
|
||||
@@ -192,6 +192,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machine",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(get_machine_parser, complete_machines)
|
||||
get_parser.set_defaults(func=get_command)
|
||||
|
||||
@@ -202,6 +207,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machine",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(remove_machine_parser, complete_machines)
|
||||
remove_parser.set_defaults(func=remove_command)
|
||||
|
||||
@@ -215,6 +225,12 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machine",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
complete_secrets,
|
||||
)
|
||||
|
||||
add_dynamic_completer(machine_add_secret_parser, complete_machines)
|
||||
add_secret_action = add_secret_parser.add_argument(
|
||||
"secret",
|
||||
@@ -234,6 +250,12 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||
help="the name of the machine",
|
||||
type=machine_name_type,
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
complete_secrets,
|
||||
)
|
||||
|
||||
add_dynamic_completer(machine_remove_parser, complete_machines)
|
||||
remove_secret_action = remove_secret_parser.add_argument(
|
||||
"secret",
|
||||
|
||||
@@ -12,14 +12,6 @@ from typing import IO
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.git import commit_files
|
||||
|
||||
from clan_cli.completions import (
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
complete_machines,
|
||||
complete_secrets,
|
||||
complete_users,
|
||||
)
|
||||
|
||||
from . import sops
|
||||
from .folders import (
|
||||
list_objects,
|
||||
@@ -39,6 +31,9 @@ from .types import VALID_SECRET_NAME, secret_name_type
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Minimum number of keys required to keep a secret group
|
||||
MIN_KEYS_FOR_GROUP_REMOVAL = 2
|
||||
|
||||
|
||||
def list_generators_secrets(generators_path: Path) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
@@ -258,6 +253,11 @@ def add_secret_argument(parser: argparse.ArgumentParser, autocomplete: bool) ->
|
||||
type=secret_name_type,
|
||||
)
|
||||
if autocomplete:
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_secrets,
|
||||
)
|
||||
|
||||
add_dynamic_completer(secrets_parser, complete_secrets)
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ def disallow_member(
|
||||
|
||||
keys = collect_keys_for_path(group_folder.parent)
|
||||
|
||||
if len(keys) < 2:
|
||||
if len(keys) < MIN_KEYS_FOR_GROUP_REMOVAL:
|
||||
msg = f"Cannot remove {name} from {group_folder.parent.name}. No keys left. Use 'clan secrets remove {name}' to remove the secret."
|
||||
raise ClanError(msg)
|
||||
target.unlink()
|
||||
@@ -465,6 +465,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
|
||||
default=[],
|
||||
help="the group to import the secrets to (can be repeated)",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_groups,
|
||||
)
|
||||
|
||||
add_dynamic_completer(set_group_action, complete_groups)
|
||||
machine_parser = parser_set.add_argument(
|
||||
"--machine",
|
||||
@@ -473,6 +478,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
|
||||
default=[],
|
||||
help="the machine to import the secrets to (can be repeated)",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_machines,
|
||||
)
|
||||
|
||||
add_dynamic_completer(machine_parser, complete_machines)
|
||||
set_user_action = parser_set.add_argument(
|
||||
"--user",
|
||||
@@ -481,6 +491,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
|
||||
default=[],
|
||||
help="the user to import the secrets to (can be repeated)",
|
||||
)
|
||||
from clan_cli.completions import ( # noqa: PLC0415
|
||||
add_dynamic_completer,
|
||||
complete_users,
|
||||
)
|
||||
|
||||
add_dynamic_completer(set_user_action, complete_users)
|
||||
parser_set.add_argument(
|
||||
"-e",
|
||||
|
||||
@@ -83,7 +83,7 @@ class KeyType(enum.Enum):
|
||||
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except Exception as ex:
|
||||
except OSError as ex:
|
||||
log.warning(f"Could not read age keys from {key_path}", exc_info=ex)
|
||||
|
||||
if keys := os.environ.get("SOPS_AGE_KEY"):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user