Compare commits
249 Commits
fix-typogr
...
pr-5024
| 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 | ||
|
|
bd93651f12 | ||
|
|
85ad51ce4c | ||
|
|
59e50c6150 | ||
|
|
f347568de3 | ||
|
|
bdad7d81b2 | ||
|
|
b8203cdf73 | ||
|
|
431e45cc3a | ||
|
|
f185d28f68 | ||
|
|
d8e6fcf773 | ||
|
|
23b7d24399 | ||
|
|
a1ed512da4 | ||
|
|
40ac96cd10 | ||
|
|
c4da43da0f | ||
|
|
8822f6dadc | ||
|
|
b5a7a91612 | ||
|
|
453b1a91a8 | ||
|
|
70274d69e9 | ||
|
|
c57d8b30d3 | ||
|
|
7407fef21b | ||
|
|
23c152541a | ||
|
|
6765e27031 | ||
|
|
cbb789bc69 | ||
|
|
7f68a21257 | ||
|
|
fc66dc78c3 | ||
|
|
1d0e0f243e | ||
|
|
8134ffd787 | ||
|
|
7f1590c729 | ||
|
|
c65bb0b1ce | ||
|
|
d8bc5269ee | ||
|
|
917407c475 | ||
|
|
d9e6e0c540 | ||
|
|
ef5ab0c2f4 | ||
|
|
34816013ad | ||
|
|
05665b1c7e | ||
|
|
2bebcab736 | ||
|
|
306f83e357 | ||
|
|
04457b1272 | ||
|
|
4986fe30c3 | ||
|
|
de33a07875 | ||
|
|
5233eb7fdb | ||
|
|
94a158b77a | ||
|
|
98af47d0b5 | ||
|
|
4470bb886e | ||
|
|
f4feac0d6b | ||
|
|
7547761812 | ||
|
|
23d11651fc | ||
|
|
03a4ac5bde | ||
|
|
ab50b433ee | ||
|
|
123e8398d8 | ||
|
|
6a2dfb8176 | ||
|
|
332d10e306 | ||
|
|
f3f6692e4d | ||
|
|
954301465f | ||
|
|
2199f4efd5 | ||
|
|
e208c02be7 | ||
|
|
7747e3cc0d | ||
|
|
1c24b4c6cb | ||
|
|
4b1ab4cdde | ||
|
|
4852e79c3c | ||
|
|
0a70ed6268 | ||
|
|
136acc7901 | ||
|
|
70d1dd0deb | ||
|
|
df32da304f | ||
|
|
76eb3c13e9 | ||
|
|
6e88046fd4 | ||
|
|
b3cafa4a8c | ||
|
|
d1cf87d2ce | ||
|
|
dc5485d9f1 | ||
|
|
1b12882e29 | ||
|
|
5be9b8383b | ||
|
|
c308fd63a7 | ||
|
|
fcdfd80b34 | ||
|
|
c5d975542d | ||
|
|
526eccdf16 | ||
|
|
f7dd34be21 | ||
|
|
51c679d3a9 | ||
|
|
470c3d330f | ||
|
|
df596ed59f | ||
|
|
f2c1202b03 | ||
|
|
4414403dec | ||
|
|
2d78730037 | ||
|
|
ec70de406b |
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
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
in
|
||||
{
|
||||
checks = pkgs.lib.mkIf pkgs.stdenv.isLinux {
|
||||
# Skip flash test on aarch64-linux for now as it's too slow
|
||||
checks = lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.hostPlatform.system != "aarch64-linux") {
|
||||
nixos-test-flash = self.clanLib.test.baseTest {
|
||||
name = "flash";
|
||||
nodes.target = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -3,10 +3,10 @@
|
||||
"clan-core-for-checks": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1756081310,
|
||||
"narHash": "sha256-wj1H5Pr6w4AsB+nG3K07SgSIDZ7jDCkGnh5XXWLdtk8=",
|
||||
"lastModified": 1756166884,
|
||||
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
|
||||
"ref": "main",
|
||||
"rev": "7b926d43dc361cd8d3ad3c14a2e7e75375b7d215",
|
||||
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://git.clan.lol/clan/clan-core"
|
||||
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1756050191,
|
||||
"narHash": "sha256-lMtTT4rv5On7D0P4Z+k7UkvbAKKuVGRbJi/VJeRCQwI=",
|
||||
"lastModified": 1756400612,
|
||||
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "759dcc6981cd4aa222d36069f78fe7064d563305",
|
||||
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
self'.packages.tea-create-pr
|
||||
self'.packages.merge-after-ci
|
||||
self'.packages.pending-reviews
|
||||
self'.packages.agit
|
||||
# treefmt with config defined in ./flake-parts/formatting.nix
|
||||
config.treefmt.build.wrapper
|
||||
];
|
||||
@@ -46,7 +45,7 @@
|
||||
ln -sfT ${inputs.nix-select} "$PRJ_ROOT/pkgs/clan-cli/clan_lib/select"
|
||||
|
||||
# Generate classes.py from schemas
|
||||
${self'.packages.classgen}/bin/classgen ${self'.legacyPackages.schemas.clan-schema-abstract}/schema.json $PRJ_ROOT/pkgs/clan-cli/clan_lib/nix_models/clan.py
|
||||
${self'.packages.classgen}/bin/classgen ${self'.legacyPackages.schemas.clanSchemaJson}/schema.json $PRJ_ROOT/pkgs/clan-cli/clan_lib/nix_models/clan.py
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -59,14 +59,15 @@ nav:
|
||||
- Configure Disk Config: guides/getting-started/choose-disk.md
|
||||
- Update Machine: guides/getting-started/update.md
|
||||
- Continuous Integration: guides/getting-started/flake-check.md
|
||||
- Using Services: guides/clanServices.md
|
||||
- Convert Existing NixOS Config: guides/getting-started/convert-flake.md
|
||||
- ClanServices: guides/clanServices.md
|
||||
- Backup & Restore: guides/backups.md
|
||||
- Disk Encryption: guides/disk-encryption.md
|
||||
- Age Plugins: guides/age-plugins.md
|
||||
- Secrets management: guides/secrets.md
|
||||
- Networking: guides/networking.md
|
||||
- Zerotier VPN: guides/mesh-vpn.md
|
||||
- Secure Boot: guides/secure-boot.md
|
||||
- How to disable Secure Boot: guides/secure-boot.md
|
||||
- Flake-parts: guides/flake-parts.md
|
||||
- macOS: guides/macos.md
|
||||
- Contributing:
|
||||
@@ -77,7 +78,6 @@ nav:
|
||||
- Writing a Service Module: guides/services/community.md
|
||||
- Writing a Disko Template: guides/disko-templates/community.md
|
||||
- Migrations:
|
||||
- Migrate existing Flakes: guides/migrations/migration-guide.md
|
||||
- Migrate from clan modules to services: guides/migrations/migrate-inventory-services.md
|
||||
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
|
||||
- Disk id: guides/migrations/disk-id.md
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Module for rendering NixOS options documentation from JSON format."""
|
||||
|
||||
# Options are available in the following format:
|
||||
# https://github.com/nixos/nixpkgs/blob/master/nixos/lib/make-options-doc/default.nix
|
||||
#
|
||||
@@ -46,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:
|
||||
@@ -173,9 +175,11 @@ def print_options(
|
||||
res += head if len(options.items()) else no_options
|
||||
for option_name, info in options.items():
|
||||
if replace_prefix:
|
||||
option_name = option_name.replace(replace_prefix + ".", "")
|
||||
display_name = option_name.replace(replace_prefix + ".", "")
|
||||
else:
|
||||
display_name = option_name
|
||||
|
||||
res += render_option(option_name, info, 4)
|
||||
res += render_option(display_name, info, 4)
|
||||
return res
|
||||
|
||||
|
||||
@@ -547,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) -->
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Machines can be added using the following methods
|
||||
|
||||
- Editing nix expressions in flake.nix (i.e. via `clan-core.lib.clan`)
|
||||
- Editing machines/`machine_name`/configuration.nix (automatically included if it exists)
|
||||
- `clan machines create` (imperative)
|
||||
- Create a file `machines/{machine_name}/configuration.nix` (See: [File Autoincludes](../../concepts/autoincludes.md))
|
||||
- Imperative via cli command: `clan machines create`
|
||||
- Editing nix expressions in flake.nix See [`clan-core.lib.clan`](/options/?scope=Flake Options (clan.nix file))
|
||||
|
||||
See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
|
||||
|
||||
@@ -39,7 +39,6 @@ See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
|
||||
The imperative command might create a machine folder in `machines/jon`
|
||||
And might persist information in `inventory.json`
|
||||
|
||||
|
||||
### Configuring a machine
|
||||
|
||||
!!! Note
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# Migrate existing NixOS configurations
|
||||
# Convert existing NixOS configurations
|
||||
|
||||
This guide will help you migrate your existing NixOS configurations into Clan.
|
||||
This guide will help you convert your existing NixOS configurations into a Clan.
|
||||
|
||||
!!! Warning
|
||||
Migrating instead of starting new can be trickier and might lead to bugs or
|
||||
unexpected issues. We recommend following the [Getting Started](../getting-started/index.md) guide first. Once you have a working setup, you can easily transfer your NixOS configurations over.
|
||||
unexpected issues. We recommend reading the [Getting Started](./index.md) guide first.
|
||||
|
||||
Once you have a working setup and understand the concepts transfering your NixOS configurations over is easy.
|
||||
|
||||
## Back up your existing configuration
|
||||
|
||||
## Back up your existing configuration!
|
||||
Before you start, it is strongly recommended to back up your existing
|
||||
configuration in any form you see fit. If you use version control to manage
|
||||
your configuration changes, it is also a good idea to follow the migration
|
||||
guide in a separte branch until everything works as expected.
|
||||
|
||||
|
||||
## Starting Point
|
||||
|
||||
We assume you are already using NixOS flakes to manage your configuration. If
|
||||
@@ -43,10 +45,9 @@ have have two hosts: **berlin** and **cologne**.
|
||||
}
|
||||
```
|
||||
|
||||
## Add clan-core Input
|
||||
## 1. Add `clan-core` to `inputs`
|
||||
|
||||
Add `clan-core` to your flake as input. It will provide everything we need to
|
||||
manage your configurations with clan.
|
||||
Add `clan-core` to your flake as input.
|
||||
|
||||
```nix
|
||||
inputs.clan-core = {
|
||||
@@ -56,7 +57,7 @@ inputs.clan-core = {
|
||||
}
|
||||
```
|
||||
|
||||
## Update Outputs
|
||||
## 2. Update Outputs
|
||||
|
||||
To be able to access our newly added dependency, it has to be added to the
|
||||
output parameters.
|
||||
@@ -103,26 +104,23 @@ For the provide flake example, your flake should now look like this:
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosConfigurations = clan.nixosConfigurations;
|
||||
|
||||
inherit (clan) clanInternals;
|
||||
|
||||
clan = {
|
||||
inherit (clan) templates;
|
||||
};
|
||||
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
|
||||
clan = clan.config;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Et voilà! Your existing hosts are now part of a clan. Existing Nix tooling
|
||||
✅ Et voilà! Your existing hosts are now part of a clan.
|
||||
|
||||
Existing Nix tooling
|
||||
should still work as normal. To check that you didn't make any errors, run `nix
|
||||
flake show` and verify both hosts are still recognized as if nothing had
|
||||
changed. You should also see the new `clanInternals` output.
|
||||
changed. You should also see the new `clan` output.
|
||||
|
||||
```
|
||||
❯ nix flake show
|
||||
git+file:///my-nixos-config
|
||||
├───clanInternals: unknown
|
||||
├───clan: unknown
|
||||
└───nixosConfigurations
|
||||
├───berlin: NixOS configuration
|
||||
└───cologne: NixOS configuration
|
||||
@@ -131,7 +129,7 @@ git+file:///my-nixos-config
|
||||
Of course you can also rebuild your configuration using `nixos-rebuild` and
|
||||
veryify everything still works.
|
||||
|
||||
## Add Clan CLI devShell
|
||||
## 3. Add `clan-cli` to your `devShells`
|
||||
|
||||
At this point Clan is set up, but you can't use the CLI yet. To do so, it is
|
||||
recommended to expose it via a `devShell` in your flake. It is also possible to
|
||||
@@ -163,8 +161,8 @@ cologne
|
||||
|
||||
## Specify Targets
|
||||
|
||||
Clan needs to know where it can reach your hosts. For each of your hosts, set
|
||||
`clan.core.networking.targetHost` to its adress or hostname.
|
||||
Clan needs to know where it can reach your hosts. For testing purpose set
|
||||
`clan.core.networking.targetHost` to the machines adress or hostname.
|
||||
|
||||
```nix
|
||||
# machines/berlin/configuration.nix
|
||||
@@ -173,6 +171,8 @@ Clan needs to know where it can reach your hosts. For each of your hosts, set
|
||||
}
|
||||
```
|
||||
|
||||
See our guide on for properly [configuring machines networking](../networking.md)
|
||||
|
||||
## Next Steps
|
||||
|
||||
You are now fully set up. Use the CLI to manage your hosts or proceed to
|
||||
@@ -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)
|
||||
|
||||
12
flake.lock
generated
@@ -31,11 +31,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755519972,
|
||||
"narHash": "sha256-bU4nqi3IpsUZJeyS8Jk85ytlX61i4b0KCxXX9YcOgVc=",
|
||||
"lastModified": 1756115622,
|
||||
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "4073ff2f481f9ef3501678ff479ed81402caae6d",
|
||||
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -99,11 +99,11 @@
|
||||
},
|
||||
"nixos-facter-modules": {
|
||||
"locked": {
|
||||
"lastModified": 1755504238,
|
||||
"narHash": "sha256-mw7q5DPdmz/1au8mY0u1DztRgVyJToGJfJszxjKSNes=",
|
||||
"lastModified": 1756291602,
|
||||
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-facter-modules",
|
||||
"rev": "354ed498c9628f32383c3bf5b6668a17cdd72a28",
|
||||
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -639,7 +639,7 @@ in
|
||||
|
||||
Exports are used to share and expose information between instances.
|
||||
|
||||
Define exports in the [`perInstance`](#perInstance) or [`perMachine`](#perMachine) scope.
|
||||
Define exports in the [`perInstance`](#roles.perInstance) or [`perMachine`](#perMachine) scope.
|
||||
|
||||
Accessing the exports:
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ let
|
||||
"secrets"
|
||||
"templates"
|
||||
];
|
||||
clanSchema = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
|
||||
clanSchemaNix = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
|
||||
|
||||
clan-schema-abstract = pkgs.stdenv.mkDerivation {
|
||||
clanSchemaJson = pkgs.stdenv.mkDerivation {
|
||||
name = "clan-schema-files";
|
||||
buildInputs = [ pkgs.cue ];
|
||||
src = ./.;
|
||||
buildPhase = ''
|
||||
export SCHEMA=${builtins.toFile "clan-schema.json" (builtins.toJSON clanSchema)}
|
||||
export SCHEMA=${builtins.toFile "clan-schema.json" (builtins.toJSON clanSchemaNix)}
|
||||
cp $SCHEMA schema.json
|
||||
# Also generate a CUE schema version that is derived from the JSON schema
|
||||
cue import -f -p compose -l '#Root:' schema.json
|
||||
@@ -41,7 +41,7 @@ in
|
||||
{
|
||||
inherit
|
||||
flakeOptions
|
||||
clanSchema
|
||||
clan-schema-abstract
|
||||
clanSchemaNix
|
||||
clanSchemaJson
|
||||
;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ in
|
||||
default = { };
|
||||
};
|
||||
tags = lib.mkOption {
|
||||
type = types.attrsOf (types.submodule { });
|
||||
type = types.coercedTo (types.listOf types.str) (t: lib.genAttrs t (_: { })) (
|
||||
types.attrsOf (types.submodule { })
|
||||
);
|
||||
default = { };
|
||||
};
|
||||
settings =
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Test driver for container-based NixOS testing."""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
@@ -11,7 +13,7 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from contextlib import _GeneratorContextManager
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from functools import cache, cached_property
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from typing import Any
|
||||
@@ -20,16 +22,10 @@ from colorama import Fore, Style
|
||||
|
||||
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
|
||||
|
||||
# Global flag to track if test environment has been initialized
|
||||
_test_env_initialized = False
|
||||
|
||||
|
||||
@cache
|
||||
def init_test_environment() -> None:
|
||||
"""Set up the test environment (network bridge, /etc/passwd) once."""
|
||||
global _test_env_initialized
|
||||
if _test_env_initialized:
|
||||
return
|
||||
|
||||
# Set up network bridge
|
||||
subprocess.run(
|
||||
["ip", "link", "add", "br0", "type", "bridge"],
|
||||
@@ -48,7 +44,7 @@ def init_test_environment() -> None:
|
||||
passwd_content = """root:x:0:0:Root:/root:/bin/sh
|
||||
nixbld:x:1000:100:Nix build user:/tmp:/bin/sh
|
||||
nobody:x:65534:65534:Nobody:/:/bin/sh
|
||||
"""
|
||||
""" # noqa: S105 - This is not a password, it's a Unix passwd file format for testing
|
||||
|
||||
with NamedTemporaryFile(mode="w", delete=False, prefix="test-passwd-") as f:
|
||||
f.write(passwd_content)
|
||||
@@ -88,8 +84,6 @@ nogroup:x:65534:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, os.strerror(errno), "Failed to mount group")
|
||||
|
||||
_test_env_initialized = True
|
||||
|
||||
|
||||
# Load the C library
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
@@ -148,7 +142,7 @@ class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_machine_root(machinename: str, root: Path) -> None:
|
||||
def prepare_machine_root(root: Path) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
root.joinpath("etc").mkdir(parents=True, exist_ok=True)
|
||||
root.joinpath(".env").write_text(
|
||||
@@ -195,7 +189,7 @@ class Machine:
|
||||
return self.get_systemd_process()
|
||||
|
||||
def start(self) -> None:
|
||||
prepare_machine_root(self.name, self.rootdir)
|
||||
prepare_machine_root(self.rootdir)
|
||||
init_test_environment()
|
||||
cmd = [
|
||||
"systemd-nspawn",
|
||||
@@ -218,8 +212,12 @@ class Machine:
|
||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, env=env)
|
||||
|
||||
def get_systemd_process(self) -> int:
|
||||
assert self.process is not None, "Machine not started"
|
||||
assert self.process.stdout is not None, "Machine has no stdout"
|
||||
if self.process is None:
|
||||
msg = "Machine not started"
|
||||
raise RuntimeError(msg)
|
||||
if self.process.stdout is None:
|
||||
msg = "Machine has no stdout"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
for line in self.process.stdout:
|
||||
print(line, end="")
|
||||
@@ -236,9 +234,9 @@ class Machine:
|
||||
.read_text()
|
||||
.split()
|
||||
)
|
||||
assert len(childs) == 1, (
|
||||
f"Expected exactly one child process for systemd-nspawn, got {childs}"
|
||||
)
|
||||
if len(childs) != 1:
|
||||
msg = f"Expected exactly one child process for systemd-nspawn, got {childs}"
|
||||
raise RuntimeError(msg)
|
||||
try:
|
||||
return int(childs[0])
|
||||
except ValueError as e:
|
||||
@@ -258,7 +256,9 @@ class Machine:
|
||||
|
||||
def tuple_from_line(line: str) -> tuple[str, str]:
|
||||
match = line_pattern.match(line)
|
||||
assert match is not None
|
||||
if match is None:
|
||||
msg = f"Failed to parse line: {line}"
|
||||
raise RuntimeError(msg)
|
||||
return match[1], match[2]
|
||||
|
||||
return dict(
|
||||
@@ -286,8 +286,8 @@ class Machine:
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
check_return: bool = True,
|
||||
check_output: bool = True,
|
||||
check_return: bool = True, # noqa: ARG002
|
||||
check_output: bool = True, # noqa: ARG002
|
||||
timeout: int | None = 900,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Execute a shell command, returning a list `(status, stdout)`.
|
||||
@@ -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,
|
||||
@@ -575,7 +574,9 @@ class Driver:
|
||||
# We lauch a sleep here, so we can pgrep the process cmdline for
|
||||
# the uuid
|
||||
sleep = shutil.which("sleep")
|
||||
assert sleep is not None, "sleep command not found"
|
||||
if sleep is None:
|
||||
msg = "sleep command not found"
|
||||
raise RuntimeError(msg)
|
||||
machine.execute(
|
||||
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
|
||||
)
|
||||
@@ -629,7 +630,7 @@ class Driver:
|
||||
|
||||
def test_script(self) -> None:
|
||||
"""Run the test script"""
|
||||
exec(self.testscript, self.test_symbols(), None)
|
||||
exec(self.testscript, self.test_symbols(), None) # noqa: S102
|
||||
|
||||
def run_tests(self) -> None:
|
||||
"""Run the test script (for non-interactive test runs)"""
|
||||
|
||||
@@ -41,15 +41,15 @@ class AbstractLogger(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def info(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||
def error(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -63,6 +63,8 @@ class AbstractLogger(ABC):
|
||||
|
||||
class JunitXMLLogger(AbstractLogger):
|
||||
class TestCaseState:
|
||||
"""State tracking for individual test cases in JUnit XML reports."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
@@ -78,6 +80,7 @@ class JunitXMLLogger(AbstractLogger):
|
||||
atexit.register(self.close)
|
||||
|
||||
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
||||
del attributes # Unused but kept for API compatibility
|
||||
self.tests[self.currentSubtest].stdout += message + os.linesep
|
||||
|
||||
@contextmanager
|
||||
@@ -86,6 +89,7 @@ class JunitXMLLogger(AbstractLogger):
|
||||
name: str,
|
||||
attributes: dict[str, str] | None = None,
|
||||
) -> Iterator[None]:
|
||||
del attributes # Unused but kept for API compatibility
|
||||
old_test = self.currentSubtest
|
||||
self.tests.setdefault(name, self.TestCaseState())
|
||||
self.currentSubtest = name
|
||||
@@ -100,16 +104,20 @@ class JunitXMLLogger(AbstractLogger):
|
||||
message: str,
|
||||
attributes: dict[str, str] | None = None,
|
||||
) -> Iterator[None]:
|
||||
del attributes # Unused but kept for API compatibility
|
||||
self.log(message)
|
||||
yield
|
||||
|
||||
def info(self, *args: Any, **kwargs: Any) -> None:
|
||||
del kwargs # Unused but kept for API compatibility
|
||||
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
|
||||
|
||||
def warning(self, *args: Any, **kwargs: Any) -> None:
|
||||
del kwargs # Unused but kept for API compatibility
|
||||
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
|
||||
|
||||
def error(self, *args: Any, **kwargs: Any) -> None:
|
||||
del kwargs # Unused but kept for API compatibility
|
||||
self.tests[self.currentSubtest].stderr += args[0] + os.linesep
|
||||
self.tests[self.currentSubtest].failure = True
|
||||
|
||||
@@ -172,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)
|
||||
@@ -237,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:
|
||||
@@ -289,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,9 +59,9 @@ class Identity:
|
||||
|
||||
def node_id(self) -> str:
|
||||
nid = self.public.split(":")[0]
|
||||
assert len(nid) == 10, (
|
||||
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
|
||||
|
||||
|
||||
@@ -84,9 +88,10 @@ class ZerotierController:
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["X-ZT1-AUTH"] = self.authtoken
|
||||
url = f"http://127.0.0.1:{self.port}{path}"
|
||||
req = urllib.request.Request(url, headers=headers, method=method, data=body)
|
||||
resp = urllib.request.urlopen(req)
|
||||
return json.load(resp)
|
||||
# Safe: only connecting to localhost zerotier API
|
||||
req = urllib.request.Request(url, headers=headers, method=method, data=body) # noqa: S310
|
||||
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
||||
return json.load(resp)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return self._http_request("/status")
|
||||
@@ -172,9 +177,9 @@ def create_identity() -> Identity:
|
||||
|
||||
|
||||
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
||||
assert len(network_id) == 16, (
|
||||
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)
|
||||
addr_parts = bytearray(
|
||||
|
||||
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]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# agit
|
||||
|
||||
A helper script for the AGit workflow with a gitea instance.
|
||||
|
||||
<!-- `$ agit --help` -->
|
||||
|
||||
```
|
||||
usage: agit [-h] {create,c,list,l} ...
|
||||
|
||||
AGit utility for creating and pulling PRs
|
||||
|
||||
positional arguments:
|
||||
{create,c,list,l} Commands
|
||||
create (c) Create an AGit PR
|
||||
list (l) List open AGit pull requests
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
|
||||
The defaults that are assumed are:
|
||||
TARGET_REMOTE_REPOSITORY = origin
|
||||
DEFAULT_TARGET_BRANCH = main
|
||||
|
||||
Examples:
|
||||
$ agit create
|
||||
Opens editor to compose PR title and description (first line is title, rest is body)
|
||||
|
||||
$ agit create --auto
|
||||
Creates PR using latest commit message automatically
|
||||
|
||||
$ agit create --topic "my-feature"
|
||||
Set a custom topic.
|
||||
|
||||
$ agit create --force
|
||||
Force push to a certain topic
|
||||
|
||||
$ agit list
|
||||
Lists all open pull requests for the current repository
|
||||
|
||||
```
|
||||
|
||||
References:
|
||||
- https://docs.gitea.com/usage/agit
|
||||
- https://git-repo.info/en/2020/03/agit-flow-and-git-repo/
|
||||
|
||||
## How to fetch AGit PR's
|
||||
|
||||
For a hypothetical PR with the number #4077:
|
||||
|
||||
```
|
||||
git fetch origin pull/4077/head:your-favorite-name
|
||||
```
|
||||
|
||||
Replace `your-favorite-name` with your preferred branch name.
|
||||
|
||||
You can push back to the PR with with:
|
||||
```
|
||||
agit create --topic="The topic of the open PR"
|
||||
```
|
||||
@@ -1,581 +0,0 @@
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# push origin HEAD:refs/for/main
|
||||
# HEAD: The target branch
|
||||
# origin: The target repository (not a fork!)
|
||||
# HEAD: The local branch containing the changes you are proposing
|
||||
TARGET_REMOTE_REPOSITORY = "origin"
|
||||
DEFAULT_TARGET_BRANCH = "main"
|
||||
|
||||
|
||||
def get_gitea_api_url(remote: str = "origin") -> str:
|
||||
"""Parse the gitea api url, this parser is fairly naive, but should work for most setups"""
|
||||
exit_code, remote_url, error = run_git_command(["git", "remote", "get-url", remote])
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error getting remote URL for '{remote}': {error}")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse different remote URL formats
|
||||
# SSH formats: git@git.clan.lol:clan/clan-core.git or gitea@git.clan.lol:clan/clan-core.git
|
||||
# HTTPS format: https://git.clan.lol/clan/clan-core.git
|
||||
|
||||
if (
|
||||
"@" in remote_url
|
||||
and ":" in remote_url
|
||||
and not remote_url.startswith("https://")
|
||||
):
|
||||
# SSH format: [user]@git.clan.lol:clan/clan-core.git
|
||||
host_and_path = remote_url.split("@")[1] # git.clan.lol:clan/clan-core.git
|
||||
host = host_and_path.split(":")[0] # git.clan.lol
|
||||
repo_path = host_and_path.split(":")[1] # clan/clan-core.git
|
||||
repo_path = repo_path.removesuffix(".git") # clan/clan-core
|
||||
elif remote_url.startswith("https://"):
|
||||
# HTTPS format: https://git.clan.lol/clan/clan-core.git
|
||||
url_parts = remote_url.replace("https://", "").split("/")
|
||||
host = url_parts[0] # git.clan.lol
|
||||
repo_path = "/".join(url_parts[1:]) # clan/clan-core.git
|
||||
if repo_path.endswith(".git"):
|
||||
repo_path = repo_path.removesuffix(".git") # clan/clan-core
|
||||
else:
|
||||
print(f"Unsupported remote URL format: {remote_url}")
|
||||
sys.exit(1)
|
||||
|
||||
api_url = f"https://{host}/api/v1/repos/{repo_path}/pulls"
|
||||
return api_url
|
||||
|
||||
|
||||
def fetch_open_prs(remote: str = "origin") -> list[dict]:
|
||||
"""Fetch open pull requests from the Gitea API."""
|
||||
api_url = get_gitea_api_url(remote)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{api_url}?state=open") as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Error fetching PRs from {api_url}: {e}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON response: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_repo_info_from_api_url(api_url: str) -> tuple[str, str]:
|
||||
"""Extract repository owner and name from API URL."""
|
||||
# api_url format: https://git.clan.lol/api/v1/repos/clan/clan-core/pulls
|
||||
parts = api_url.split("/")
|
||||
if len(parts) >= 6 and "repos" in parts:
|
||||
repo_index = parts.index("repos")
|
||||
if repo_index + 2 < len(parts):
|
||||
owner = parts[repo_index + 1]
|
||||
repo_name = parts[repo_index + 2]
|
||||
return owner, repo_name
|
||||
msg = f"Invalid API URL format: {api_url}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def fetch_pr_statuses(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
commit_sha: str,
|
||||
host: str,
|
||||
) -> list[dict]:
|
||||
"""Fetch CI statuses for a specific commit SHA."""
|
||||
status_url = (
|
||||
f"https://{host}/api/v1/repos/{repo_owner}/{repo_name}/statuses/{commit_sha}"
|
||||
)
|
||||
|
||||
try:
|
||||
request = urllib.request.Request(status_url)
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
||||
# Fail silently for individual status requests to keep listing fast
|
||||
return []
|
||||
|
||||
|
||||
def get_latest_status_by_context(statuses: list[dict]) -> dict[str, str]:
|
||||
"""Group statuses by context and return the latest status for each context."""
|
||||
context_statuses = {}
|
||||
|
||||
for status in statuses:
|
||||
context = status.get("context", "unknown")
|
||||
created_at = status.get("created_at", "")
|
||||
status_state = status.get("status", "unknown")
|
||||
|
||||
if (
|
||||
context not in context_statuses
|
||||
or created_at > context_statuses[context]["created_at"]
|
||||
):
|
||||
context_statuses[context] = {
|
||||
"status": status_state,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
return {context: info["status"] for context, info in context_statuses.items()}
|
||||
|
||||
|
||||
def status_to_emoji(status: str) -> str:
|
||||
"""Convert status string to emoji."""
|
||||
status_map = {"success": "✅", "failure": "❌", "pending": "🟡", "error": "❓"}
|
||||
return status_map.get(status.lower(), "❓")
|
||||
|
||||
|
||||
def create_osc8_link(url: str, text: str) -> str:
|
||||
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
||||
|
||||
|
||||
def format_pr_with_status(pr: dict, remote: str = "origin") -> str:
|
||||
"""Format PR title with status emojis and OSC8 link."""
|
||||
title = pr["title"]
|
||||
pr_url = pr.get("html_url", "")
|
||||
|
||||
commit_sha = pr.get("head", {}).get("sha")
|
||||
if not commit_sha:
|
||||
if pr_url:
|
||||
return create_osc8_link(pr_url, title)
|
||||
return title
|
||||
|
||||
try:
|
||||
api_url = get_gitea_api_url(remote)
|
||||
repo_owner, repo_name = get_repo_info_from_api_url(api_url)
|
||||
|
||||
host = api_url.split("/")[2]
|
||||
|
||||
statuses = fetch_pr_statuses(repo_owner, repo_name, commit_sha, host)
|
||||
if not statuses:
|
||||
if pr_url:
|
||||
return create_osc8_link(pr_url, title)
|
||||
return title
|
||||
|
||||
latest_statuses = get_latest_status_by_context(statuses)
|
||||
|
||||
emojis = [status_to_emoji(status) for status in latest_statuses.values()]
|
||||
formatted_title = f"{title} {' '.join(emojis)}" if emojis else title
|
||||
|
||||
return create_osc8_link(pr_url, formatted_title) if pr_url else formatted_title
|
||||
|
||||
except (ValueError, IndexError):
|
||||
# If there's any error in processing, just return the title with link if available
|
||||
if pr_url:
|
||||
return create_osc8_link(pr_url, title)
|
||||
|
||||
return title
|
||||
|
||||
|
||||
def run_git_command(command: list) -> tuple[int, str, str]:
|
||||
"""Run a git command and return exit code, stdout, and stderr."""
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def get_current_branch_name() -> str:
|
||||
exit_code, branch_name, error = run_git_command(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error getting branch name: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
return branch_name.strip()
|
||||
|
||||
|
||||
def get_latest_commit_info() -> tuple[str, str]:
|
||||
"""Get the title and body of the latest commit."""
|
||||
exit_code, commit_msg, error = run_git_command(
|
||||
["git", "log", "-1", "--pretty=format:%B"],
|
||||
)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error getting commit info: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
lines = commit_msg.strip().split("\n")
|
||||
title = lines[0].strip() if lines else ""
|
||||
|
||||
body_lines = []
|
||||
for line in lines[1:]:
|
||||
if body_lines or line.strip():
|
||||
body_lines.append(line)
|
||||
|
||||
body = "\n".join(body_lines).strip()
|
||||
|
||||
return title, body
|
||||
|
||||
|
||||
def get_commits_since_main() -> list[tuple[str, str]]:
|
||||
"""Get all commits since main as (title, body) tuples."""
|
||||
exit_code, commit_log, error = run_git_command(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"main..HEAD",
|
||||
"--no-merges",
|
||||
"--pretty=format:%s|%b|---END---",
|
||||
],
|
||||
)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error getting commits since main: {error}")
|
||||
return []
|
||||
|
||||
if not commit_log:
|
||||
return []
|
||||
|
||||
commits = []
|
||||
commit_messages = commit_log.split("---END---")
|
||||
|
||||
for commit_msg in commit_messages:
|
||||
commit_msg = commit_msg.strip()
|
||||
if not commit_msg:
|
||||
continue
|
||||
|
||||
parts = commit_msg.split("|")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
title = parts[0].strip()
|
||||
body = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if not title:
|
||||
continue
|
||||
|
||||
commits.append((title, body))
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
def open_editor_for_pr() -> tuple[str, str]:
|
||||
"""Open editor to get PR title and description. First line is title, rest is description."""
|
||||
commits_since_main = get_commits_since_main()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w+",
|
||||
suffix="COMMIT_EDITMSG",
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
for title, body in commits_since_main:
|
||||
temp_file.write(f"{title}\n")
|
||||
if body:
|
||||
temp_file.write(f"{body}\n")
|
||||
temp_file.write("\n")
|
||||
|
||||
temp_file.write("\n")
|
||||
temp_file.write("# Please enter the PR title on the first line.\n")
|
||||
temp_file.write("# Lines starting with '#' will be ignored.\n")
|
||||
temp_file.write("# The first line will be used as the PR title.\n")
|
||||
temp_file.write("# Everything else will be used as the PR description.\n")
|
||||
temp_file.write(
|
||||
"# To abort creation of the PR, close editor with an error code.\n",
|
||||
)
|
||||
temp_file.write("# In vim for example you can use :cq!\n")
|
||||
temp_file.write("#\n")
|
||||
temp_file.write("# All commits since main:\n")
|
||||
temp_file.write("#\n")
|
||||
for i, (title, body) in enumerate(commits_since_main, 1):
|
||||
temp_file.write(f"# Commit {i}:\n")
|
||||
temp_file.write(f"# {title}\n")
|
||||
if body:
|
||||
for line in body.split("\n"):
|
||||
temp_file.write(f"# {line}\n")
|
||||
temp_file.write("#\n")
|
||||
|
||||
try:
|
||||
editor = os.environ.get("EDITOR", "vim")
|
||||
|
||||
exit_code = subprocess.call([editor, temp_file_path])
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Editor exited with code {exit_code}.")
|
||||
print("AGit PR creation has been aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
with Path(temp_file_path).open() as f:
|
||||
content = f.read()
|
||||
|
||||
lines = []
|
||||
for line in content.split("\n"):
|
||||
if not line.lstrip().startswith("#"):
|
||||
lines.append(line)
|
||||
|
||||
cleaned_content = "\n".join(lines).strip()
|
||||
|
||||
if not cleaned_content:
|
||||
print("No content provided, aborting.")
|
||||
sys.exit(0)
|
||||
|
||||
content_lines = cleaned_content.split("\n")
|
||||
title = content_lines[0].strip()
|
||||
|
||||
if not title:
|
||||
print("No title provided, aborting.")
|
||||
sys.exit(0)
|
||||
|
||||
description_lines = []
|
||||
for line in content_lines[1:]:
|
||||
if description_lines or line.strip():
|
||||
description_lines.append(line)
|
||||
|
||||
description = "\n".join(description_lines).strip()
|
||||
|
||||
return title, description
|
||||
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(temp_file_path).unlink()
|
||||
|
||||
|
||||
def create_agit_push(
|
||||
remote: str = "origin",
|
||||
branch: str = "main",
|
||||
topic: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
force_push: bool = False,
|
||||
local_branch: str = "HEAD",
|
||||
) -> None:
|
||||
if topic is None:
|
||||
if title is not None:
|
||||
topic = title
|
||||
else:
|
||||
topic = get_current_branch_name()
|
||||
|
||||
refspec = f"{local_branch}:refs/for/{branch}"
|
||||
push_cmd = ["git", "push", remote, refspec]
|
||||
|
||||
push_cmd.extend(["-o", f"topic={topic}"])
|
||||
|
||||
if title:
|
||||
push_cmd.extend(["-o", f"title={title}"])
|
||||
|
||||
if description:
|
||||
escaped_desc = description.rstrip("\n").replace('"', '\\"')
|
||||
push_cmd.extend(["-o", f"description={escaped_desc}"])
|
||||
|
||||
if force_push:
|
||||
push_cmd.extend(["-o", "force-push"])
|
||||
|
||||
if description:
|
||||
print(
|
||||
f" Description: {description[:50]}..."
|
||||
if len(description) > 50
|
||||
else f" Description: {description}",
|
||||
)
|
||||
print()
|
||||
|
||||
exit_code, stdout, stderr = run_git_command(push_cmd)
|
||||
|
||||
if stdout:
|
||||
print(stdout)
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
if exit_code != 0:
|
||||
print("\nPush failed!")
|
||||
sys.exit(exit_code)
|
||||
else:
|
||||
print("\nPush successful!")
|
||||
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> None:
|
||||
"""Handle the create subcommand."""
|
||||
title = args.title
|
||||
description = args.description
|
||||
|
||||
if not args.auto and (title is None or description is None):
|
||||
editor_title, editor_description = open_editor_for_pr()
|
||||
if title is None:
|
||||
title = editor_title
|
||||
if description is None:
|
||||
description = editor_description
|
||||
|
||||
create_agit_push(
|
||||
remote=args.remote,
|
||||
branch=args.branch,
|
||||
topic=args.topic,
|
||||
title=title,
|
||||
description=description,
|
||||
force_push=args.force,
|
||||
local_branch=args.local_branch,
|
||||
)
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> None:
|
||||
"""Handle the list subcommand."""
|
||||
prs = fetch_open_prs(args.remote)
|
||||
|
||||
if not prs:
|
||||
print("No open AGit pull requests found.")
|
||||
return
|
||||
|
||||
# This is the only way I found to query the actual AGit PRs
|
||||
# Gitea doesn't seem to have an actual api endpoint for them
|
||||
filtered_prs = [pr for pr in prs if pr.get("head", {}).get("label", "") == ""]
|
||||
|
||||
if not filtered_prs:
|
||||
print("No open AGit pull requests found.")
|
||||
return
|
||||
|
||||
for pr in filtered_prs:
|
||||
formatted_pr = format_pr_with_status(pr, args.remote)
|
||||
print(formatted_pr)
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agit",
|
||||
description="AGit utility for creating and pulling PRs",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f"""
|
||||
The defaults that are assumed are:
|
||||
TARGET_REMOTE_REPOSITORY = {TARGET_REMOTE_REPOSITORY}
|
||||
DEFAULT_TARGET_BRANCH = {DEFAULT_TARGET_BRANCH}
|
||||
|
||||
Examples:
|
||||
$ agit create
|
||||
Opens editor to compose PR title and description (first line is title, rest is body)
|
||||
|
||||
$ agit create --auto
|
||||
Creates PR using latest commit message automatically
|
||||
|
||||
$ agit create --topic "my-feature"
|
||||
Set a custom topic.
|
||||
|
||||
$ agit create --force
|
||||
Force push to a certain topic
|
||||
|
||||
$ agit list
|
||||
Lists all open pull requests for the current repository
|
||||
""",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Commands")
|
||||
|
||||
create_parser = subparsers.add_parser(
|
||||
"create",
|
||||
aliases=["c"],
|
||||
help="Create an AGit PR",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
$ agit create
|
||||
Opens editor to compose PR title and description (first line is title, rest is body).
|
||||
|
||||
$ agit create --auto
|
||||
Creates PR using latest commit message automatically (old behavior).
|
||||
|
||||
$ agit create --topic "my-feature"
|
||||
Set a custom topic.
|
||||
|
||||
$ agit create --force
|
||||
Force push to a certain topic
|
||||
""",
|
||||
)
|
||||
|
||||
list_parser = subparsers.add_parser(
|
||||
"list",
|
||||
aliases=["l"],
|
||||
help="List open AGit pull requests",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f"""
|
||||
Examples:
|
||||
$ agit list
|
||||
Lists all open AGit PRs for the current repository.
|
||||
|
||||
$ agit list --remote upstream
|
||||
Lists PRs using the 'upstream' remote instead of '{TARGET_REMOTE_REPOSITORY}'.
|
||||
""",
|
||||
)
|
||||
|
||||
list_parser.add_argument(
|
||||
"-r",
|
||||
"--remote",
|
||||
default=TARGET_REMOTE_REPOSITORY,
|
||||
help=f"Git remote to use for fetching PRs (default: {TARGET_REMOTE_REPOSITORY})",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-r",
|
||||
"--remote",
|
||||
default=TARGET_REMOTE_REPOSITORY,
|
||||
help=f"Git remote to push to (default: {TARGET_REMOTE_REPOSITORY})",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-b",
|
||||
"--branch",
|
||||
default=DEFAULT_TARGET_BRANCH,
|
||||
help=f"Target branch for the PR (default: {DEFAULT_TARGET_BRANCH})",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-l",
|
||||
"--local-branch",
|
||||
default="HEAD",
|
||||
help="Local branch to push (default: HEAD)",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-t",
|
||||
"--topic",
|
||||
help="Set PR topic (default: current branch name)",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"--title",
|
||||
help="Set the PR title (default: last commit title)",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"--description",
|
||||
help="Override the PR description (default: commit body)",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force push the changes",
|
||||
)
|
||||
|
||||
create_parser.add_argument(
|
||||
"-a",
|
||||
"--auto",
|
||||
action="store_true",
|
||||
help="Skip editor and use commit message automatically",
|
||||
)
|
||||
|
||||
create_parser.set_defaults(func=cmd_create)
|
||||
list_parser.set_defaults(func=cmd_list)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
if args.subcommand is None:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
bash,
|
||||
callPackage,
|
||||
git,
|
||||
lib,
|
||||
openssh,
|
||||
...
|
||||
}:
|
||||
let
|
||||
writers = callPackage ../builders/script-writers.nix { };
|
||||
in
|
||||
writers.writePython3Bin "agit" {
|
||||
flakeIgnore = [
|
||||
"E501"
|
||||
"W503" # treefmt reapplies the conditions to trigger this check
|
||||
];
|
||||
makeWrapperArgs = [
|
||||
"--prefix"
|
||||
"PATH"
|
||||
":"
|
||||
(lib.makeBinPath [
|
||||
bash
|
||||
git
|
||||
openssh
|
||||
])
|
||||
];
|
||||
} ./agit.py
|
||||
@@ -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:
|
||||
@@ -121,7 +119,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
||||
webview.add_middleware(MethodExecutionMiddleware(api=API))
|
||||
|
||||
webview.bind_jsonschema_api(API, log_manager=log_manager)
|
||||
webview.bind_jsonschema_api(API)
|
||||
webview.navigate(content_uri)
|
||||
webview.run()
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 726 B |
|
Before Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -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),
|
||||
@@ -313,7 +313,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
)
|
||||
return
|
||||
|
||||
self._process_api_request_in_thread(api_request, method_name)
|
||||
self._process_api_request_in_thread(api_request)
|
||||
|
||||
def _parse_request_data(
|
||||
self,
|
||||
@@ -363,7 +363,6 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
def _process_api_request_in_thread(
|
||||
self,
|
||||
api_request: BackendRequest,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
"""Process the API request in a separate thread."""
|
||||
stop_event = threading.Event()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -171,7 +156,7 @@ class TestHttpApiServer:
|
||||
data=json.dumps(request_data).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = urlopen(req)
|
||||
response = urlopen(req) # noqa: S310
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
# Response should be BackendResponse format
|
||||
@@ -207,7 +192,7 @@ class TestHttpApiServer:
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
res = urlopen(req)
|
||||
res = urlopen(req) # noqa: S310
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
@@ -219,7 +204,7 @@ class TestHttpApiServer:
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
res = urlopen(req)
|
||||
res = urlopen(req) # noqa: S310
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
@@ -240,7 +225,7 @@ class TestHttpApiServer:
|
||||
return "OPTIONS"
|
||||
|
||||
req: Request = OptionsRequest("http://127.0.0.1:8081/api/call/test_method")
|
||||
response = urlopen(req)
|
||||
response = urlopen(req) # noqa: S310
|
||||
|
||||
# Check CORS headers
|
||||
headers = response.info()
|
||||
@@ -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
|
||||
@@ -290,7 +273,7 @@ class TestIntegration:
|
||||
data=json.dumps(request_data).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = urlopen(req)
|
||||
response = urlopen(req) # noqa: S310
|
||||
data: dict = json.loads(response.read().decode())
|
||||
|
||||
# Verify response in BackendResponse format
|
||||
@@ -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
|
||||
@@ -341,7 +322,7 @@ class TestIntegration:
|
||||
data=json.dumps(request_data).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = urlopen(req)
|
||||
response = urlopen(req) # noqa: S310
|
||||
data: dict = json.loads(response.read().decode())
|
||||
|
||||
# thread.join()
|
||||
@@ -365,7 +346,7 @@ class TestIntegration:
|
||||
data=json.dumps(request_data).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = urlopen(req)
|
||||
response = urlopen(req) # noqa: S310
|
||||
data: dict = json.loads(response.read().decode())
|
||||
|
||||
assert "body" in data
|
||||
|
||||
@@ -10,15 +10,13 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from clan_lib.api import MethodRegistry, message_queue
|
||||
from clan_lib.api.tasks import WebThread
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
@@ -50,14 +48,15 @@ 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)
|
||||
|
||||
def _create_handle(self) -> None:
|
||||
# Initialize the webview handle
|
||||
handle = _webview_lib.webview_create(int(self.debug), self.window)
|
||||
with_debugger = True
|
||||
handle = _webview_lib.webview_create(int(with_debugger), self.window)
|
||||
callbacks: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
# Since we can't use object.__setattr__, we'll initialize differently
|
||||
@@ -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
|
||||
|
||||
@@ -99,23 +98,24 @@ class Webview:
|
||||
"""Get the bridge, creating it if necessary."""
|
||||
if self._bridge is None:
|
||||
self.create_bridge()
|
||||
assert self._bridge is not None, "Bridge should be created"
|
||||
if self._bridge is None:
|
||||
msg = "Bridge should be created"
|
||||
raise RuntimeError(msg)
|
||||
return self._bridge
|
||||
|
||||
def api_wrapper(
|
||||
self,
|
||||
method_name: str,
|
||||
wrap_method: Callable[..., Any],
|
||||
op_key_bytes: bytes,
|
||||
request_data: bytes,
|
||||
arg: int,
|
||||
) -> None:
|
||||
"""Legacy API wrapper - delegates to the bridge."""
|
||||
del arg # Unused but required for C callback signature
|
||||
self.bridge.handle_webview_call(
|
||||
method_name=method_name,
|
||||
op_key_bytes=op_key_bytes,
|
||||
request_data=request_data,
|
||||
arg=arg,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -131,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(
|
||||
@@ -184,12 +182,11 @@ class Webview:
|
||||
log.info("Shutting down webview...")
|
||||
self.destroy()
|
||||
|
||||
def bind_jsonschema_api(self, api: MethodRegistry, log_manager: LogManager) -> None:
|
||||
for name, method in api.functions.items():
|
||||
def bind_jsonschema_api(self, api: MethodRegistry) -> None:
|
||||
for name in api.functions:
|
||||
wrapper = functools.partial(
|
||||
self.api_wrapper,
|
||||
name,
|
||||
method,
|
||||
)
|
||||
c_callback = _webview_lib.binding_callback_t(wrapper)
|
||||
|
||||
@@ -206,12 +203,12 @@ class Webview:
|
||||
)
|
||||
|
||||
def bind(self, name: str, callback: Callable[..., Any]) -> None:
|
||||
def wrapper(seq: bytes, req: bytes, arg: int) -> None:
|
||||
def wrapper(seq: bytes, req: bytes, _arg: int) -> None:
|
||||
args = json.loads(req.decode())
|
||||
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(
|
||||
@@ -39,7 +40,6 @@ class WebviewBridge(ApiBridge):
|
||||
method_name: str,
|
||||
op_key_bytes: bytes,
|
||||
request_data: bytes,
|
||||
arg: int,
|
||||
) -> None:
|
||||
"""Handle a call from webview's JavaScript bridge."""
|
||||
try:
|
||||
|
||||
@@ -31,7 +31,7 @@ let
|
||||
desktop-file = makeDesktopItem {
|
||||
name = "org.clan.app";
|
||||
exec = "clan-app %u";
|
||||
icon = "clan-white";
|
||||
icon = "clan-app";
|
||||
desktopName = "Clan App";
|
||||
startupWMClass = "clan";
|
||||
mimeTypes = [ "x-scheme-handler/clan" ];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,6 +24,14 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
// we need to separate output the input otherwise it interferes with prop binding
|
||||
const [_, rootProps] = splitProps(props, ["input"]);
|
||||
|
||||
const [styleProps, otherRootProps] = splitProps(rootProps, [
|
||||
"class",
|
||||
"size",
|
||||
"orientation",
|
||||
"inverted",
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
const alignment = () =>
|
||||
(props.orientation || "vertical") == "vertical" ? "start" : "center";
|
||||
|
||||
@@ -47,14 +55,21 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
|
||||
return (
|
||||
<KCheckbox.Root
|
||||
class={cx("form-field", "checkbox", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...rootProps}
|
||||
class={cx(
|
||||
styleProps.class,
|
||||
"form-field",
|
||||
"checkbox",
|
||||
styleProps.size,
|
||||
styleProps.orientation,
|
||||
{
|
||||
inverted: styleProps.inverted,
|
||||
ghost: styleProps.ghost,
|
||||
},
|
||||
)}
|
||||
{...otherRootProps}
|
||||
>
|
||||
{(state) => (
|
||||
<Orienter orientation={props.orientation} align={alignment()}>
|
||||
<Orienter orientation={styleProps.orientation} align={alignment()}>
|
||||
<Label
|
||||
labelComponent={KCheckbox.Label}
|
||||
descriptionComponent={KCheckbox.Description}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface FieldProps {
|
||||
class?: string;
|
||||
label?: string;
|
||||
labelWeight?: "bold" | "normal";
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
ghost?: boolean;
|
||||
|
||||
@@ -11,7 +11,7 @@ import styles from "./HostFileInput.module.css";
|
||||
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import { createSignal } from "solid-js";
|
||||
import { createSignal, splitProps } from "solid-js";
|
||||
import { Tooltip } from "@kobalte/core/tooltip";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
|
||||
@@ -40,17 +40,31 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const [styleProps, otherProps] = splitProps(props, [
|
||||
"class",
|
||||
"size",
|
||||
"orientation",
|
||||
"inverted",
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
class={cx("form-field", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...props}
|
||||
class={cx(
|
||||
styleProps.class,
|
||||
"form-field",
|
||||
styleProps.size,
|
||||
styleProps.orientation,
|
||||
{
|
||||
inverted: styleProps.inverted,
|
||||
ghost: styleProps.ghost,
|
||||
},
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<Orienter
|
||||
orientation={props.orientation}
|
||||
align={props.orientation == "horizontal" ? "center" : "start"}
|
||||
orientation={styleProps.orientation}
|
||||
align={styleProps.orientation == "horizontal" ? "center" : "start"}
|
||||
>
|
||||
<Label
|
||||
labelComponent={TextField.Label}
|
||||
@@ -70,12 +84,12 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
||||
{!value() && (
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
size={props.size}
|
||||
size={styleProps.size}
|
||||
startIcon="Folder"
|
||||
onClick={selectFile}
|
||||
disabled={props.disabled || props.readOnly}
|
||||
class={cx(
|
||||
props.orientation === "vertical"
|
||||
styleProps.orientation === "vertical"
|
||||
? styles.vertical_button
|
||||
: styles.horizontal_button,
|
||||
)}
|
||||
@@ -92,7 +106,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={!props.inverted}
|
||||
inverted={!styleProps.inverted}
|
||||
>
|
||||
{value()}
|
||||
</Typography>
|
||||
@@ -107,7 +121,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
||||
: styles.horizontal_button,
|
||||
)}
|
||||
hierarchy="secondary"
|
||||
size={props.size}
|
||||
size={styleProps.size}
|
||||
startIcon="Folder"
|
||||
onClick={selectFile}
|
||||
disabled={props.disabled || props.readOnly}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface LabelProps {
|
||||
descriptionComponent: DescriptionComponent;
|
||||
size?: Size;
|
||||
label?: string;
|
||||
labelWeight?: "bold" | "normal";
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
@@ -46,7 +47,7 @@ export const Label = (props: LabelProps) => {
|
||||
hierarchy="label"
|
||||
size={props.size || "default"}
|
||||
color={props.validationState == "invalid" ? "error" : "primary"}
|
||||
weight={props.readOnly ? "normal" : "bold"}
|
||||
weight={props.labelWeight || "bold"}
|
||||
inverted={props.inverted}
|
||||
>
|
||||
{props.label}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -85,6 +85,14 @@ export const TextArea = (props: TextAreaProps) => {
|
||||
"maxRows",
|
||||
]);
|
||||
|
||||
const [styleProps, otherProps] = splitProps(props, [
|
||||
"class",
|
||||
"size",
|
||||
"orientation",
|
||||
"inverted",
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
ref={(el: HTMLDivElement) => {
|
||||
@@ -92,13 +100,20 @@ export const TextArea = (props: TextAreaProps) => {
|
||||
// but not in webkit, so we capture the parent ref and query for the textarea
|
||||
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
|
||||
}}
|
||||
class={cx("form-field", "textarea", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...props}
|
||||
class={cx(
|
||||
styleProps.class,
|
||||
"form-field",
|
||||
"textarea",
|
||||
styleProps.size,
|
||||
styleProps.orientation,
|
||||
{
|
||||
inverted: styleProps.inverted,
|
||||
ghost: styleProps.ghost,
|
||||
},
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<Orienter orientation={props.orientation} align={"start"}>
|
||||
<Orienter orientation={styleProps.orientation} align={"start"}>
|
||||
<Label
|
||||
labelComponent={TextField.Label}
|
||||
descriptionComponent={TextField.Description}
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./TextInput.css";
|
||||
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import { splitProps } from "solid-js";
|
||||
|
||||
export type TextInputProps = FieldProps &
|
||||
TextFieldRootProps & {
|
||||
@@ -19,15 +20,30 @@ export type TextInputProps = FieldProps &
|
||||
};
|
||||
|
||||
export const TextInput = (props: TextInputProps) => {
|
||||
const [styleProps, otherProps] = splitProps(props, [
|
||||
"class",
|
||||
"size",
|
||||
"orientation",
|
||||
"inverted",
|
||||
"ghost",
|
||||
]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
class={cx("form-field", "text", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...props}
|
||||
class={cx(
|
||||
styleProps.class,
|
||||
"form-field",
|
||||
"text",
|
||||
styleProps.size,
|
||||
styleProps.orientation,
|
||||
{
|
||||
inverted: styleProps.inverted,
|
||||
ghost: styleProps.ghost,
|
||||
},
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<Orienter orientation={props.orientation}>
|
||||
<Orienter orientation={styleProps.orientation}>
|
||||
<Label
|
||||
labelComponent={TextField.Label}
|
||||
descriptionComponent={TextField.Description}
|
||||
@@ -37,7 +53,7 @@ export const TextInput = (props: TextInputProps) => {
|
||||
{props.icon && !props.readOnly && (
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
inverted={props.inverted}
|
||||
inverted={styleProps.inverted}
|
||||
color={props.disabled ? "tertiary" : "quaternary"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createContext,
|
||||
createSignal,
|
||||
useContext,
|
||||
ParentComponent,
|
||||
} from "solid-js";
|
||||
import { Dialog as KDialog } from "@kobalte/core/dialog";
|
||||
import styles from "./Modal.module.css";
|
||||
@@ -27,6 +28,10 @@ export const useModalContext = () => {
|
||||
return context as ModalContextType;
|
||||
};
|
||||
|
||||
const defaultContentWrapper: ParentComponent = (props): JSX.Element => (
|
||||
<>{props.children}</>
|
||||
);
|
||||
|
||||
export interface ModalProps {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -35,53 +40,70 @@ export interface ModalProps {
|
||||
mount?: Node;
|
||||
class?: string;
|
||||
metaHeader?: Component;
|
||||
wrapContent?: ParentComponent;
|
||||
disablePadding?: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export const Modal = (props: ModalProps) => {
|
||||
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
|
||||
|
||||
// allows wrapping the dialog content in a component
|
||||
// useful with forms where the submit button is in the header
|
||||
const contentWrapper: Component = props.wrapContent || defaultContentWrapper;
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<KDialog id={props.id} open={props.open} modal={true}>
|
||||
<KDialog.Portal mount={props.mount}>
|
||||
<div class={styles.backdrop} />
|
||||
<div class={styles.contentWrapper}>
|
||||
<KDialog.Content class={cx(styles.modal_content, props.class)}>
|
||||
<div class={styles.modal_header}>
|
||||
<Typography
|
||||
class={styles.modal_title}
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<Show when={props.onClose}>
|
||||
<KDialog.CloseButton onClick={props.onClose}>
|
||||
<Icon icon="Close" size="0.75rem" />
|
||||
</KDialog.CloseButton>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.metaHeader}>
|
||||
{(metaHeader) => (
|
||||
<KDialog.Content
|
||||
class={cx(styles.modal_content, props.class)}
|
||||
onEscapeKeyDown={props.onClose}
|
||||
>
|
||||
{contentWrapper({
|
||||
children: (
|
||||
<>
|
||||
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
|
||||
<Dynamic component={metaHeader()} />
|
||||
<div class={styles.modal_header}>
|
||||
<Typography
|
||||
class={styles.modal_title}
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<Show when={props.onClose}>
|
||||
<KDialog.CloseButton onClick={props.onClose}>
|
||||
<Icon icon="Close" size="0.75rem" />
|
||||
</KDialog.CloseButton>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.metaHeader}>
|
||||
{(metaHeader) => (
|
||||
<>
|
||||
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
|
||||
<Dynamic component={metaHeader()} />
|
||||
</div>
|
||||
<div class={styles.header_divider} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class={styles.modal_body}
|
||||
data-no-padding={props.disablePadding}
|
||||
ref={setPortalRef}
|
||||
>
|
||||
<ModalContext.Provider
|
||||
value={{ portalRef: portalRef()! }}
|
||||
>
|
||||
{props.children}
|
||||
</ModalContext.Provider>
|
||||
</div>
|
||||
<div class={styles.header_divider} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class={styles.modal_body}
|
||||
data-no-padding={props.disablePadding}
|
||||
ref={setPortalRef}
|
||||
>
|
||||
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
|
||||
{props.children}
|
||||
</ModalContext.Provider>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
</KDialog.Content>
|
||||
</div>
|
||||
</KDialog.Portal>
|
||||
|
||||
237
pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Button } from "../Button/Button";
|
||||
import styles from "./Search.module.css";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
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;
|
||||
initialValues?: 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 [inputValue, setInputValue] = createSignal<string>("");
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
|
||||
let listboxRef: HTMLUListElement;
|
||||
|
||||
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
|
||||
const [comboboxItems, setComboboxItems] = createSignal<CollectionNode<T>[]>(
|
||||
props.options.map((item) => ({
|
||||
rawValue: item,
|
||||
})) as CollectionNode<T>[],
|
||||
);
|
||||
|
||||
// Create a reactive virtualizer that updates when items change
|
||||
const virtualizer = createMemo(() => {
|
||||
const items = comboboxItems();
|
||||
|
||||
const newVirtualizer = createVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => listboxRef,
|
||||
getItemKey: (index) => {
|
||||
const item = items[index];
|
||||
return item?.rawValue?.value || `item-${index}`;
|
||||
},
|
||||
estimateSize: () => 42,
|
||||
gap: 0,
|
||||
overscan: 5,
|
||||
...props.virtualizerOptions,
|
||||
});
|
||||
|
||||
return newVirtualizer;
|
||||
});
|
||||
createEffect(() => {
|
||||
console.log("multi values:", props.values);
|
||||
});
|
||||
return (
|
||||
<Combobox<T>
|
||||
multiple
|
||||
value={props.values}
|
||||
onChange={(values) => {
|
||||
// setValues(() => values);
|
||||
console.log("onChange", values);
|
||||
props.onChange(values);
|
||||
}}
|
||||
class={styles.searchContainer}
|
||||
placement="bottom-start"
|
||||
options={props.options}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
modal={false}
|
||||
flip={false}
|
||||
virtualized={true}
|
||||
allowsEmptyCollection={true}
|
||||
closeOnSelection={false}
|
||||
triggerMode="manual"
|
||||
noResetInputOnBlur={true}
|
||||
>
|
||||
<Combobox.Control<T>
|
||||
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
|
||||
>
|
||||
{(state) => (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
<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 (
|
||||
<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`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<For each={virtualizer().getVirtualItems()}>
|
||||
{(virtualRow) => {
|
||||
const item: CollectionNode<T> | undefined =
|
||||
items().getItem(virtualRow.key as string);
|
||||
|
||||
if (!item) {
|
||||
console.warn("Item not found for key:", virtualRow.key);
|
||||
return null;
|
||||
}
|
||||
const isSelected = () =>
|
||||
props.values.some(
|
||||
(v) => v.value === item.rawValue.value,
|
||||
);
|
||||
return (
|
||||
<Combobox.Item
|
||||
item={item}
|
||||
class={cx(
|
||||
styles.searchItem,
|
||||
props.divider && styles.hasDivider,
|
||||
)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{props.renderItem(item.rawValue, {
|
||||
selected: isSelected(),
|
||||
disabled: item.disabled,
|
||||
})}
|
||||
</Combobox.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</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,42 +42,48 @@
|
||||
}
|
||||
|
||||
.searchItem {
|
||||
@apply flex py-1 px-2 pr-4 gap-2 justify-between items-center rounded-md;
|
||||
@apply flex flex-col justify-center overflow-hidden;
|
||||
|
||||
& [role="option"] {
|
||||
@apply flex flex-col w-full;
|
||||
&.hasDivider {
|
||||
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
& [role="complementary"] {
|
||||
@apply size-8 flex items-center justify-center bg-white rounded-md;
|
||||
/* Next element is hovered */
|
||||
&:has(+ &:hover) {
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&[data-highlighted],
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
@apply bg-inv-acc-2;
|
||||
&: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;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply bg-inv-acc-3;
|
||||
&:not([aria-disabled="true"]):active {
|
||||
@apply bg-inv-acc-3 rounded-md;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
@apply bg-gradient-to-b from-bg-inv-3 to-bg-inv-4;
|
||||
|
||||
@apply h-[14.5rem] rounded-lg;
|
||||
@apply rounded-lg;
|
||||
|
||||
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:
|
||||
@@ -85,9 +91,8 @@
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.searchContent {
|
||||
@apply px-3;
|
||||
height: calc(14.5rem - 4rem);
|
||||
.listbox {
|
||||
@apply px-3 pt-3.5;
|
||||
}
|
||||
|
||||
@keyframes contentHide {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
|
||||
import { Search, SearchProps, Module } from "./Search";
|
||||
import { Search, SearchProps } from "./Search";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import {
|
||||
ItemRenderOptions,
|
||||
SearchMultiple,
|
||||
SearchMultipleProps,
|
||||
} from "./MultipleSearch";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Search",
|
||||
component: Search,
|
||||
} satisfies Meta<SearchProps>;
|
||||
} satisfies Meta<SearchProps<unknown>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SearchProps>;
|
||||
type Story = StoryObj<SearchProps<unknown>>;
|
||||
|
||||
// To test the virtualizer, we can generate a list of modules
|
||||
function generateModules(count: number): Module[] {
|
||||
@@ -45,24 +54,63 @@ function generateModules(count: number): Module[] {
|
||||
for (let i = 0; i < count; i++) {
|
||||
modules.push({
|
||||
value: `lolcat/module-${i + 1}`,
|
||||
name: `Module ${i + 1}`,
|
||||
description: `${greek[i % greek.length]}#${i + 1}`,
|
||||
input: "lolcat",
|
||||
label: `Module ${i + 1}`,
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
value: string;
|
||||
label: string;
|
||||
input: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
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 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">
|
||||
<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-72 truncate align-middle">
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-20 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
render: (args: SearchProps) => {
|
||||
render: (args: SearchProps<Module>) => {
|
||||
return (
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<Search
|
||||
<div class="fixed bottom-10 left-1/2 mb-2 w-[30rem] -translate-x-1/2">
|
||||
<Search<Module>
|
||||
{...args}
|
||||
onChange={(module) => {
|
||||
// Go to the module configuration
|
||||
@@ -73,3 +121,134 @@ 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";
|
||||
};
|
||||
|
||||
const machinesAndTags: MachineOrTag[] = [
|
||||
{ value: "machine-1", label: "Machine 1", type: "machine" },
|
||||
{ value: "machine-2", label: "Machine 2", type: "machine" },
|
||||
{
|
||||
value: "all",
|
||||
label: "All",
|
||||
type: "tag",
|
||||
members: ["machine-1", "machine-2", "machine-3", "machine-4", "machine-5"],
|
||||
},
|
||||
{
|
||||
value: "tag-1",
|
||||
label: "Tag 1",
|
||||
type: "tag",
|
||||
members: ["machine-1", "machine-2"],
|
||||
},
|
||||
{ value: "machine-3", label: "Machine 3", type: "machine" },
|
||||
{ value: "machine-4", label: "Machine 4", type: "machine" },
|
||||
{ value: "machine-5", label: "Machine 5", type: "machine" },
|
||||
{
|
||||
value: "tag-2",
|
||||
label: "Tag 2",
|
||||
type: "tag",
|
||||
members: ["machine-3", "machine-4", "machine-5"],
|
||||
},
|
||||
];
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
// Test with lots of modules
|
||||
options: machinesAndTags,
|
||||
placeholder: "Search for Machine or Tags",
|
||||
renderItem: (item: MachineOrTag, opts: ItemRenderOptions) => {
|
||||
console.log("Rendering item:", item, "opts", opts);
|
||||
return (
|
||||
<div class="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
|
||||
color={opts.disabled ? "quaternary" : "primary"}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
},
|
||||
},
|
||||
render: (args: SearchMultipleProps<MachineOrTag>) => {
|
||||
return (
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<SearchMultiple<MachineOrTag>
|
||||
{...args}
|
||||
divider
|
||||
height="20rem"
|
||||
virtualizerOptions={{
|
||||
estimateSize: () => 38,
|
||||
}}
|
||||
onChange={(selection) => {
|
||||
// Go to the module configuration
|
||||
console.log("Currently Selected:", selection);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,25 +2,32 @@ 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 } from "solid-js";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
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 Module {
|
||||
export interface Option {
|
||||
value: string;
|
||||
name: string;
|
||||
input: string;
|
||||
description: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
onChange: (value: Module | null) => void;
|
||||
options: Module[];
|
||||
export interface SearchProps<T> {
|
||||
onChange: (value: T | null) => void;
|
||||
options: T[];
|
||||
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(props: SearchProps) {
|
||||
|
||||
export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
// Controlled input value, to allow resetting the input itself
|
||||
const [value, setValue] = createSignal<Module | null>(null);
|
||||
const [value, setValue] = createSignal<T | null>(null);
|
||||
const [inputValue, setInputValue] = createSignal<string>("");
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
@@ -28,12 +35,10 @@ export function Search(props: SearchProps) {
|
||||
let listboxRef: HTMLUListElement;
|
||||
|
||||
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
|
||||
const [comboboxItems, setComboboxItems] = createSignal<
|
||||
CollectionNode<Module>[]
|
||||
>(
|
||||
const [comboboxItems, setComboboxItems] = createSignal<CollectionNode<T>[]>(
|
||||
props.options.map((item) => ({
|
||||
rawValue: item,
|
||||
})) as CollectionNode<Module>[],
|
||||
})) as CollectionNode<T>[],
|
||||
);
|
||||
|
||||
// Create a reactive virtualizer that updates when items change
|
||||
@@ -56,20 +61,21 @@ export function Search(props: SearchProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox<Module>
|
||||
<Combobox<T>
|
||||
value={value()}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
setInputValue(value ? value.name : "");
|
||||
setValue(() => value);
|
||||
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="name"
|
||||
optionLabel="name"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
placeholder="Search a service"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
@@ -81,7 +87,9 @@ export function Search(props: SearchProps) {
|
||||
triggerMode="manual"
|
||||
noResetInputOnBlur={true}
|
||||
>
|
||||
<Combobox.Control<Module> 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" />
|
||||
@@ -115,31 +123,39 @@ export function Search(props: SearchProps) {
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Control>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
|
||||
<Combobox.Listbox<Module>
|
||||
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`,
|
||||
@@ -149,7 +165,7 @@ export function Search(props: SearchProps) {
|
||||
>
|
||||
<For each={virtualizer().getVirtualItems()}>
|
||||
{(virtualRow) => {
|
||||
const item: CollectionNode<Module> | undefined =
|
||||
const item: CollectionNode<T> | undefined =
|
||||
items().getItem(virtualRow.key as string);
|
||||
|
||||
if (!item) {
|
||||
@@ -169,42 +185,19 @@ export function Search(props: SearchProps) {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div role="complementary">
|
||||
<Icon icon="Code" />
|
||||
</div>
|
||||
<div role="option">
|
||||
<Combobox.ItemLabel class="flex">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
>
|
||||
{item.rawValue.name}
|
||||
</Typography>
|
||||
</Combobox.ItemLabel>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xxs"
|
||||
weight="normal"
|
||||
color="quaternary"
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span>{item.rawValue.description}</span>
|
||||
<span>by {item.rawValue.input}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
{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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,20 +3,18 @@ import Icon from "@/src/components/Icon/Icon";
|
||||
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import { createSignal, For, Suspense, useContext } from "solid-js";
|
||||
import {
|
||||
navigateToClan,
|
||||
navigateToOnboarding,
|
||||
useClanURI,
|
||||
} from "@/src/hooks/clan";
|
||||
import { createSignal, For, Show, Suspense, useContext } from "solid-js";
|
||||
import { navigateToOnboarding } from "@/src/hooks/clan";
|
||||
import { setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Button } from "../Button/Button";
|
||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
|
||||
|
||||
export const SidebarHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
|
||||
// get information about the current active clan
|
||||
const ctx = useContext(ClanContext);
|
||||
@@ -25,20 +23,27 @@ export const SidebarHeader = () => {
|
||||
throw new Error("SidebarContext not found");
|
||||
}
|
||||
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const clanChar = () =>
|
||||
ctx?.activeClanQuery?.data?.name.charAt(0).toUpperCase();
|
||||
const clanName = () => ctx?.activeClanQuery?.data?.name;
|
||||
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
|
||||
const clanName = () => ctx?.activeClanQuery?.data?.details.name;
|
||||
|
||||
const clanList = () =>
|
||||
ctx.allClansQueries
|
||||
.filter((it) => it.isSuccess)
|
||||
.map((it) => it.data!)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => a.details.name.localeCompare(b.details.name));
|
||||
|
||||
return (
|
||||
<div class="sidebar-header">
|
||||
<Show when={ctx.activeClanQuery.isSuccess && showSettings()}>
|
||||
<ClanSettingsModal
|
||||
model={ctx.activeClanQuery.data!}
|
||||
onClose={() => {
|
||||
ctx?.activeClanQuery?.refetch(); // refresh clan data
|
||||
setShowSettings(false);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Suspense fallback={"Loading..."}>
|
||||
<DropdownMenu open={open()} onOpenChange={setOpen} sameWidth={true}>
|
||||
<DropdownMenu.Trigger class="dropdown-trigger">
|
||||
@@ -70,7 +75,7 @@ export const SidebarHeader = () => {
|
||||
<DropdownMenu.Content class="sidebar-dropdown-content">
|
||||
<DropdownMenu.Item
|
||||
class="dropdown-item"
|
||||
onSelect={() => navigateToClan(navigate, clanURI)}
|
||||
onSelect={() => setShowSettings(true)}
|
||||
>
|
||||
<Icon
|
||||
icon="Settings"
|
||||
@@ -118,7 +123,7 @@ export const SidebarHeader = () => {
|
||||
size="xs"
|
||||
weight="medium"
|
||||
>
|
||||
{clan.name}
|
||||
{clan.details.name}
|
||||
</Typography>
|
||||
</DropdownMenu.Item>
|
||||
</Suspense>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -10,8 +10,11 @@ import { useApiClient } from "./ApiClient";
|
||||
import { experimental_createQueryPersister } from "@tanstack/solid-query-persist-client";
|
||||
import { ClanDetailsStore } from "@/src/stores/clanDetails";
|
||||
|
||||
export type ClanDetails = SuccessData<"get_clan_details">;
|
||||
export type ClanDetailsWithURI = ClanDetails & { uri: string };
|
||||
export interface ClanDetails {
|
||||
uri: string;
|
||||
details: SuccessData<"get_clan_details">;
|
||||
fieldsSchema: SuccessData<"get_clan_details_schema">;
|
||||
}
|
||||
|
||||
export type Tags = SuccessData<"list_tags">;
|
||||
export type Machine = SuccessData<"get_machine">;
|
||||
@@ -29,7 +32,7 @@ export interface MachineDetail {
|
||||
}
|
||||
|
||||
export type MachinesQueryResult = UseQueryResult<ListMachines>;
|
||||
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
|
||||
export type ClanListQueryResult = UseQueryResult<ClanDetails>[];
|
||||
|
||||
export const DefaultQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -39,6 +42,7 @@ export const DefaultQueryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
export type MachinesQuery = ReturnType<typeof useMachinesQuery>;
|
||||
export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
@@ -65,7 +69,7 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
return useQuery<MachineDetail>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryFn: async () => {
|
||||
const [tagsCall, machineCall, schemaCall] = await Promise.all([
|
||||
const [tagsCall, machineCall, schemaCall] = [
|
||||
client.fetch("list_tags", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
@@ -85,7 +89,7 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
];
|
||||
|
||||
const tags = await tagsCall.result;
|
||||
if (tags.status === "error") {
|
||||
@@ -114,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: {
|
||||
@@ -176,26 +200,45 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
|
||||
|
||||
export const useClanDetailsQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ClanDetailsWithURI>(() => ({
|
||||
return useQuery<ClanDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
persister: ClanDetailsPersister.persisterFn,
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_clan_details", {
|
||||
const args = {
|
||||
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", clanURI, result.errors);
|
||||
throw new Error(result.errors[0].message);
|
||||
const [detailsCall, schemaCall] = [
|
||||
client.fetch("get_clan_details", args),
|
||||
client.fetch("get_clan_details_schema", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const details = await detailsCall.result;
|
||||
|
||||
if (details.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching clan details: " + details.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
const schema = await schemaCall.result;
|
||||
|
||||
if (schema.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching clan details schema: " + schema.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
uri: clanURI,
|
||||
...result.data,
|
||||
details: details.data!,
|
||||
fieldsSchema: schema.data,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -230,21 +273,41 @@ export const useClanListQuery = (
|
||||
}
|
||||
}
|
||||
|
||||
const call = client.fetch("get_clan_details", {
|
||||
const args = {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
});
|
||||
const result = await call.result;
|
||||
};
|
||||
|
||||
if (result.status === "error") {
|
||||
// todo should we create some specific error types?
|
||||
throw new Error(result.errors[0].message);
|
||||
const [detailsCall, schemaCall] = [
|
||||
client.fetch("get_clan_details", args),
|
||||
client.fetch("get_clan_details_schema", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const details = await detailsCall.result;
|
||||
|
||||
if (details.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching clan details: " + details.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
const schema = await schemaCall.result;
|
||||
|
||||
if (schema.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching clan details schema: " + schema.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
uri: clanURI,
|
||||
...result.data,
|
||||
details: details.data,
|
||||
fieldsSchema: schema.data,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -392,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,
|
||||
@@ -414,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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.modal {
|
||||
@apply w-screen max-w-xl h-fit flex flex-col;
|
||||
|
||||
.content {
|
||||
@apply flex flex-col gap-3 size-full;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex w-full items-center justify-between;
|
||||
}
|
||||
|
||||
.remove {
|
||||
@apply flex justify-between items-center px-4 py-5 gap-8;
|
||||
@apply rounded-md bg-semantic-error-2 border-[0.0625rem] border-semantic-error-4;
|
||||
|
||||
.clanInput {
|
||||
@apply grow;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fn } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { ClanSettingsModal, ClanSettingsModalProps } from "./ClanSettingsModal";
|
||||
|
||||
const meta: Meta<ClanSettingsModalProps> = {
|
||||
title: "Modals/ClanSettings",
|
||||
component: ClanSettingsModal,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ClanSettingsModalProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onClose: fn(),
|
||||
model: {
|
||||
uri: "/home/foo/my-clan",
|
||||
name: "Sol",
|
||||
description: null,
|
||||
icon: null,
|
||||
fieldsSchema: {
|
||||
name: {
|
||||
readonly: true,
|
||||
reason: null,
|
||||
},
|
||||
description: {
|
||||
readonly: false,
|
||||
reason: null,
|
||||
},
|
||||
icon: {
|
||||
readonly: false,
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import cx from "classnames";
|
||||
import styles from "./ClanSettingsModal.module.css";
|
||||
import { Modal } from "@/src/components/Modal/Modal";
|
||||
import { ClanDetails } from "@/src/hooks/queries";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
createForm,
|
||||
getErrors,
|
||||
Maybe,
|
||||
SubmitHandler,
|
||||
valiForm,
|
||||
} from "@modular-forms/solid";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { tooltipText } from "@/src/components/Form";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
import { createSignal, Show, splitProps } from "solid-js";
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { Alert } from "@/src/components/Alert/Alert";
|
||||
import { removeClanURI } from "@/src/stores/clan";
|
||||
|
||||
const schema = v.object({
|
||||
name: v.pipe(v.optional(v.string())),
|
||||
description: v.nullish(v.string()),
|
||||
icon: v.pipe(v.nullish(v.string())),
|
||||
});
|
||||
|
||||
export interface ClanSettingsModalProps {
|
||||
model: ClanDetails;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FieldNames = "name" | "description" | "icon";
|
||||
type FormValues = Pick<ClanDetails["details"], "name" | "description" | "icon">;
|
||||
|
||||
export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<FormValues>({
|
||||
initialValues: props.model.details,
|
||||
validate: valiForm<FormValues>(schema),
|
||||
});
|
||||
|
||||
const readOnly = (name: FieldNames) =>
|
||||
props.model.fieldsSchema[name]?.readonly ?? false;
|
||||
|
||||
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
||||
if (!formStore.dirty) {
|
||||
// nothing to save, just close the modal
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// we only save stuff when the form is dirty
|
||||
setSaving(true);
|
||||
|
||||
const call = callApi("set_clan_details", {
|
||||
options: {
|
||||
flake: {
|
||||
identifier: props.model.uri,
|
||||
},
|
||||
meta: {
|
||||
// todo we don't support icon field yet, so we mixin the original fields to stop the API from complaining
|
||||
// about deleting a field
|
||||
...props.model.details,
|
||||
...values,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await call.result;
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (result.status == "error") {
|
||||
throw new Error(`Failed to save changes: ${result.errors[0].message}`);
|
||||
}
|
||||
|
||||
if (result.status == "success") {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = (): Maybe<string> => {
|
||||
const formErrors = getErrors(formStore);
|
||||
|
||||
const firstFormError = Object.values(formErrors).find(
|
||||
(value) => value,
|
||||
) as Maybe<string>;
|
||||
|
||||
return firstFormError || formStore.response.message;
|
||||
};
|
||||
|
||||
const [removeValue, setRemoveValue] = createSignal("");
|
||||
|
||||
const removeDisabled = () => removeValue() !== props.model.details.name;
|
||||
|
||||
const onRemove = () => {
|
||||
removeClanURI(props.model.uri);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
class={cx(styles.modal)}
|
||||
open
|
||||
title="Settings"
|
||||
onClose={props.onClose}
|
||||
wrapContent={(props) => (
|
||||
<Form onSubmit={handleSubmit}>{props.children}</Form>
|
||||
)}
|
||||
metaHeader={() => (
|
||||
<div class={cx(styles.header)}>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="default"
|
||||
weight="medium"
|
||||
>
|
||||
{props.model.details.name}
|
||||
</Typography>
|
||||
<Button hierarchy="primary" size="s" type="submit" loading={saving()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class={cx(styles.content)}>
|
||||
<Show when={errorMessage()}>
|
||||
<Alert type="error" title="Error" description={errorMessage()} />
|
||||
</Show>
|
||||
<Fieldset>
|
||||
<Field name="name">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
label="Name"
|
||||
required
|
||||
readOnly={readOnly("name")}
|
||||
orientation="horizontal"
|
||||
input={input}
|
||||
tooltip={tooltipText(
|
||||
"name",
|
||||
props.model.fieldsSchema,
|
||||
"A unique identifier for this Clan",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Divider />
|
||||
<Field name="description">
|
||||
{(field, input) => (
|
||||
<TextArea
|
||||
{...splitProps(field, ["value"])[1]}
|
||||
defaultValue={field.value ?? ""}
|
||||
label="Description"
|
||||
readOnly={readOnly("description")}
|
||||
tooltip={tooltipText(
|
||||
"description",
|
||||
props.model.fieldsSchema,
|
||||
"A description of this Clan",
|
||||
)}
|
||||
orientation="horizontal"
|
||||
input={{
|
||||
...input,
|
||||
autoResize: true,
|
||||
minRows: 2,
|
||||
maxRows: 4,
|
||||
placeholder: "No description",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
|
||||
<div class={cx(styles.remove)}>
|
||||
<TextInput
|
||||
class={cx(styles.clanInput)}
|
||||
orientation="horizontal"
|
||||
onChange={setRemoveValue}
|
||||
input={{
|
||||
value: removeValue(),
|
||||
placeholder: "Enter the name of this Clan",
|
||||
onKeyDown: (event) => {
|
||||
if (event.key == "Enter" && !removeDisabled()) {
|
||||
onRemove();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
size="s"
|
||||
startIcon="Trash"
|
||||
disabled={removeDisabled()}
|
||||
onClick={onRemove}
|
||||
>
|
||||
Remove Clan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,13 @@ import cx from "classnames";
|
||||
import styles from "./ListClansModal.module.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan";
|
||||
import { navigateToOnboarding } from "@/src/hooks/clan";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { For, Show } from "solid-js";
|
||||
import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { useClanListQuery } from "@/src/hooks/queries";
|
||||
import { Alert } from "@/src/components/Alert/Alert";
|
||||
import { NavSection } from "../NavSection/NavSection";
|
||||
import { NavSection } from "@/src/components/NavSection/NavSection";
|
||||
|
||||
export interface ListClansModalProps {
|
||||
onClose?: () => void;
|
||||
@@ -30,7 +30,8 @@ export const ListClansModal = (props: ListClansModalProps) => {
|
||||
|
||||
const selectClan = (uri: string) => () => {
|
||||
if (uri == activeClanURI()) {
|
||||
navigateToClan(navigate, uri);
|
||||
// this is the easiest way of reloading the clan
|
||||
window.location.reload();
|
||||
} else {
|
||||
setActiveClanURI(uri);
|
||||
}
|
||||
@@ -81,8 +82,8 @@ export const ListClansModal = (props: ListClansModalProps) => {
|
||||
{(clan) => (
|
||||
<li>
|
||||
<NavSection
|
||||
label={clan.data.name}
|
||||
description={clan.data.description ?? undefined}
|
||||
label={clan.data.details.name}
|
||||
description={clan.data.details.description ?? undefined}
|
||||
onClick={selectClan(clan.data.uri)}
|
||||
/>
|
||||
</li>
|
||||
@@ -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,10 +14,11 @@ import {
|
||||
buildMachinePath,
|
||||
maybeUseMachineName,
|
||||
useClanURI,
|
||||
useMachineName,
|
||||
} from "@/src/hooks/clan";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||
import {
|
||||
ClanDetailsWithURI,
|
||||
ClanDetails,
|
||||
MachinesQueryResult,
|
||||
useClanDetailsQuery,
|
||||
useClanListQuery,
|
||||
@@ -35,14 +36,20 @@ import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/components/ListClansModal/ListClansModal";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
machinesQuery: MachinesQueryResult;
|
||||
activeClanQuery: UseQueryResult<ClanDetailsWithURI>;
|
||||
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[];
|
||||
allClansQueries: UseQueryResult<ClanDetailsWithURI>[];
|
||||
activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
isLoading(): boolean;
|
||||
isError(): boolean;
|
||||
@@ -51,9 +58,9 @@ interface ClanContextProps {
|
||||
class DefaultClanContext implements ClanContextProps {
|
||||
public readonly clanURI: string;
|
||||
|
||||
public readonly activeClanQuery: UseQueryResult<ClanDetailsWithURI>;
|
||||
public readonly otherClanQueries: UseQueryResult<ClanDetailsWithURI>[];
|
||||
public readonly allClansQueries: UseQueryResult<ClanDetailsWithURI>[];
|
||||
public readonly activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
public readonly allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
public readonly machinesQuery: MachinesQueryResult;
|
||||
|
||||
@@ -62,8 +69,8 @@ class DefaultClanContext implements ClanContextProps {
|
||||
constructor(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
activeClanQuery: UseQueryResult<ClanDetailsWithURI>,
|
||||
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[],
|
||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||
) {
|
||||
this.clanURI = clanURI;
|
||||
this.machinesQuery = machinesQuery;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||