Compare commits
222 Commits
mdformat
...
revers-upd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e17d9ec0f | ||
|
|
87ea942399 | ||
|
|
39a032a285 | ||
|
|
a06940e981 | ||
|
|
4aebfadc8a | ||
|
|
f45f26994e | ||
|
|
c777a1a2b9 | ||
|
|
36fe7822f7 | ||
|
|
0ccf3310f9 | ||
|
|
a8d6552caa | ||
|
|
a131448dcf | ||
|
|
14a52dbc2e | ||
|
|
565391bd8c | ||
|
|
9bffa2a774 | ||
|
|
e42a07423e | ||
|
|
c5178ac16a | ||
|
|
33791e06cd | ||
|
|
c7e3bf624e | ||
|
|
ba027c2239 | ||
|
|
25fdabee29 | ||
|
|
de69c63ee3 | ||
|
|
b9573636d8 | ||
|
|
3862ad2a06 | ||
|
|
c447aec9d3 | ||
|
|
5137d19b0f | ||
|
|
453f2649d3 | ||
|
|
58cfcf3d25 | ||
|
|
c260a97cc1 | ||
|
|
3eb64870b0 | ||
|
|
7412b958c6 | ||
|
|
a0c27194a6 | ||
|
|
3437af29cb | ||
|
|
0b1c12d2e5 | ||
|
|
8620761bbd | ||
|
|
d793b6ca07 | ||
|
|
17e9231657 | ||
|
|
acc2674d79 | ||
|
|
c34a21a3bb | ||
|
|
275bff23da | ||
|
|
1a766a3447 | ||
|
|
c22844c83b | ||
|
|
5472ca0e21 | ||
|
|
ad890b0b6b | ||
|
|
a364b5ebf3 | ||
|
|
d0134d131e | ||
|
|
ccf0dace11 | ||
|
|
9977a903ce | ||
|
|
dc9bf5068e | ||
|
|
6b4f79c9fa | ||
|
|
b2985b59e9 | ||
|
|
d4ac3b83ee | ||
|
|
00bf55be5a | ||
|
|
851d6aaa89 | ||
|
|
f007279bee | ||
|
|
5a3381d9ff | ||
|
|
83e51db2e7 | ||
|
|
4e4af8a52f | ||
|
|
54a8ec717e | ||
|
|
d3e5e6edf1 | ||
|
|
a4277ad312 | ||
|
|
8877f2d451 | ||
|
|
9275b66bd9 | ||
|
|
6a964f37d5 | ||
|
|
73f2a4f56f | ||
|
|
85fb0187ee | ||
|
|
db9812a08b | ||
|
|
ca69530591 | ||
|
|
fc5b0e4113 | ||
|
|
278af5f0f4 | ||
|
|
e7baf25ff7 | ||
|
|
fada75144c | ||
|
|
803ef5476f | ||
|
|
016bd263d0 | ||
|
|
f9143f8a5d | ||
|
|
92eb27fcb1 | ||
|
|
0cc9b91ae8 | ||
|
|
2ed3608e34 | ||
|
|
a92a1a7dd1 | ||
|
|
9a903be6d4 | ||
|
|
adea270b27 | ||
|
|
765eb142a5 | ||
|
|
faa1405d6b | ||
|
|
0c93aab818 | ||
|
|
56923ae2c3 | ||
|
|
e2f64e1d40 | ||
|
|
c574b84278 | ||
|
|
640f15d55e | ||
|
|
789d326273 | ||
|
|
1763d85d91 | ||
|
|
082fa05083 | ||
|
|
9ed7190606 | ||
|
|
6c22539dd4 | ||
|
|
e6819ede61 | ||
|
|
186a760529 | ||
|
|
a84aee7b0c | ||
|
|
cab2fa44ba | ||
|
|
5962149e55 | ||
|
|
00f9d08a4b | ||
|
|
3d0c843308 | ||
|
|
847138472b | ||
|
|
c7786a59fd | ||
|
|
3b2d357f10 | ||
|
|
a83dbf604c | ||
|
|
f77456a123 | ||
|
|
6e4c3a638d | ||
|
|
3d2127ce1e | ||
|
|
a4a5916fa2 | ||
|
|
f6727055cd | ||
|
|
0517d87caa | ||
|
|
89e587592c | ||
|
|
439495d738 | ||
|
|
0b2fd681be | ||
|
|
41de615331 | ||
|
|
b7639b1d81 | ||
|
|
602879c9e4 | ||
|
|
53e16242b9 | ||
|
|
24c5146763 | ||
|
|
dca7aa0487 | ||
|
|
647bc4e4df | ||
|
|
1c80223fe3 | ||
|
|
7ac9b00398 | ||
|
|
d37c9e3b04 | ||
|
|
0fe9d0e157 | ||
|
|
5479c767c1 | ||
|
|
edc389ba4b | ||
|
|
4cb17d42e1 | ||
|
|
f26499edb8 | ||
|
|
2857cb7ed8 | ||
|
|
3168fecd52 | ||
|
|
24c20ff243 | ||
|
|
8ba8fda54b | ||
|
|
0992a47b00 | ||
|
|
d5b09f18ed | ||
|
|
fb2fe36c87 | ||
|
|
3db51887b1 | ||
|
|
24f3bcca57 | ||
|
|
85006c8103 | ||
|
|
db5571d623 | ||
|
|
d4bdaec586 | ||
|
|
cb9c8e5b5a | ||
|
|
0a1802c341 | ||
|
|
dfae1a4429 | ||
|
|
c1dc73a21b | ||
|
|
8145740cc1 | ||
|
|
b2a54f5b0d | ||
|
|
9c9adc6e16 | ||
|
|
f7cde8eb0f | ||
|
|
501d020562 | ||
|
|
a9bafd71e1 | ||
|
|
166e4b8081 | ||
|
|
c3eb40f17a | ||
|
|
7330285150 | ||
|
|
8cf8573c61 | ||
|
|
5bfa0d7a9d | ||
|
|
8ea2dd9b72 | ||
|
|
6efcade56a | ||
|
|
6d2372be56 | ||
|
|
626af4691b | ||
|
|
63697ac4b1 | ||
|
|
0ebb1f0c66 | ||
|
|
1dda60847e | ||
|
|
a7bce4cb19 | ||
|
|
a5474bc25f | ||
|
|
f634b8f1fb | ||
|
|
0ad40a0233 | ||
|
|
78abc36cd3 | ||
|
|
f5158b068f | ||
|
|
e6066a6cb1 | ||
|
|
fc8b66effa | ||
|
|
16b92963fd | ||
|
|
2ff3d871ac | ||
|
|
108936ef07 | ||
|
|
c45d4cfec9 | ||
|
|
64217e1281 | ||
|
|
d1421bb534 | ||
|
|
ac20514a8e | ||
|
|
79c4e73a15 | ||
|
|
61a647b436 | ||
|
|
c9a709783a | ||
|
|
c55b369899 | ||
|
|
084b8bacd3 | ||
|
|
47ad7d8a95 | ||
|
|
3798808013 | ||
|
|
43a39267f3 | ||
|
|
db94ea2d2e | ||
|
|
f0533f9bba | ||
|
|
360048fd04 | ||
|
|
8f8426de52 | ||
|
|
4bce390e64 | ||
|
|
2b7837e2b6 | ||
|
|
cbf9678534 | ||
|
|
b38b10c9a6 | ||
|
|
31cbb7dc00 | ||
|
|
0fa4377793 | ||
|
|
7b0d10e8c2 | ||
|
|
bb41adab4b | ||
|
|
648aa7dc59 | ||
|
|
3073969c92 | ||
|
|
2f1dc3a33d | ||
|
|
b707dcea2d | ||
|
|
4f0c8025b2 | ||
|
|
b91bee537a | ||
|
|
7207a3e8cd | ||
|
|
ac675a5af0 | ||
|
|
64caebde62 | ||
|
|
4934884e0c | ||
|
|
22cd9baee2 | ||
|
|
84232b5355 | ||
|
|
5bc7c255c1 | ||
|
|
d11d83f699 | ||
|
|
2ef1b2a8fa | ||
|
|
f7414d7e6e | ||
|
|
ab384150b2 | ||
|
|
0b6939ffee | ||
|
|
bc6a1a9d17 | ||
|
|
7055461cf0 | ||
|
|
a9564df6a9 | ||
|
|
e2dfc74d02 | ||
|
|
326cb60aea | ||
|
|
68b264970a | ||
|
|
1fa4ef82e9 | ||
|
|
ec70de406b |
2
.github/workflows/repo-sync.yml
vendored
2
.github/workflows/repo-sync.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'clan-lol'
|
if: github.repository_owner == 'clan-lol'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/create-github-app-token@v2
|
- uses: actions/create-github-app-token@v2
|
||||||
|
|||||||
@@ -302,7 +302,8 @@
|
|||||||
"test-install-machine-without-system",
|
"test-install-machine-without-system",
|
||||||
"-i", ssh_conn.ssh_key,
|
"-i", ssh_conn.ssh_key,
|
||||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
"--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)
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
@@ -326,7 +327,9 @@
|
|||||||
"test-install-machine-without-system",
|
"test-install-machine-without-system",
|
||||||
"-i", ssh_conn.ssh_key,
|
"-i", ssh_conn.ssh_key,
|
||||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
"--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)
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
|
|||||||
68
clanServices/coredns/README.md
Normal file
68
clanServices/coredns/README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
This module enables hosting clan-internal services easily, which can be resolved
|
||||||
|
inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`)
|
||||||
|
and exposing endpoints from a machine to others, which will be
|
||||||
|
accessible under `http://<service>.clan` in your browser.
|
||||||
|
|
||||||
|
The service consists of two roles:
|
||||||
|
|
||||||
|
- A `server` role: This is the DNS-server that will be queried when trying to
|
||||||
|
resolve clan-internal services. It defines the top-level domain.
|
||||||
|
- A `default` role: This does two things. First, it sets up the nameservers so
|
||||||
|
thatclan-internal queries are resolved via the `server` machine, while
|
||||||
|
external queries are resolved as normal via DHCP. Second, it allows exposing
|
||||||
|
services (see example below).
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
Here the machine `dnsserver` is designated as internal DNS-server for the TLD
|
||||||
|
`.foo`. `server01` will host an application that shall be reachable at
|
||||||
|
`http://one.foo` and `server02` is going to be reachable at `http://two.foo`.
|
||||||
|
`client` is any other machine that is part of the clan but does not host any
|
||||||
|
services.
|
||||||
|
|
||||||
|
When `client` tries to resolve `http://one.foo`, the DNS query will be
|
||||||
|
routed to `dnsserver`, which will answer with `192.168.1.3`. If it tries to
|
||||||
|
resolve some external domain (e.g. `https://clan.lol`), the query will not be
|
||||||
|
routed to `dnsserver` but resolved as before, via the nameservers advertised by
|
||||||
|
DHCP.
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inventory = {
|
||||||
|
|
||||||
|
machines = {
|
||||||
|
dnsserver = { }; # 192.168.1.2
|
||||||
|
server01 = { }; # 192.168.1.3
|
||||||
|
server02 = { }; # 192.168.1.4
|
||||||
|
client = { }; # 192.168.1.5
|
||||||
|
};
|
||||||
|
|
||||||
|
instances = {
|
||||||
|
coredns = {
|
||||||
|
|
||||||
|
module.name = "@clan/coredns";
|
||||||
|
module.input = "self";
|
||||||
|
|
||||||
|
# Add the default role to all machines, including `client`
|
||||||
|
roles.default.tags.all = { };
|
||||||
|
|
||||||
|
# DNS server
|
||||||
|
roles.server.machines."dnsserver".settings = {
|
||||||
|
ip = "192.168.1.2";
|
||||||
|
tld = "foo";
|
||||||
|
};
|
||||||
|
|
||||||
|
# First service
|
||||||
|
roles.default.machines."server01".settings = {
|
||||||
|
ip = "192.168.1.3";
|
||||||
|
services = [ "one" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Second service
|
||||||
|
roles.default.machines."server02".settings = {
|
||||||
|
ip = "192.168.1.4";
|
||||||
|
services = [ "two" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
157
clanServices/coredns/default.nix
Normal file
157
clanServices/coredns/default.nix
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
_class = "clan.service";
|
||||||
|
manifest.name = "coredns";
|
||||||
|
manifest.description = "Clan-internal DNS and service exposure";
|
||||||
|
manifest.categories = [ "Network" ];
|
||||||
|
manifest.readme = builtins.readFile ./README.md;
|
||||||
|
|
||||||
|
roles.server = {
|
||||||
|
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
options.tld = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "clan";
|
||||||
|
description = ''
|
||||||
|
Top-level domain for this instance. All services below this will be
|
||||||
|
resolved internally.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.ip = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
# TODO: Set a default
|
||||||
|
description = "IP for the DNS to listen on";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
perInstance =
|
||||||
|
{
|
||||||
|
roles,
|
||||||
|
settings,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
nixosModule =
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = [ 53 ];
|
||||||
|
networking.firewall.allowedUDPPorts = [ 53 ];
|
||||||
|
|
||||||
|
services.coredns =
|
||||||
|
let
|
||||||
|
|
||||||
|
# Get all service entries for one host
|
||||||
|
hostServiceEntries =
|
||||||
|
host:
|
||||||
|
lib.strings.concatStringsSep "\n" (
|
||||||
|
map (
|
||||||
|
service: "${service} IN A ${roles.default.machines.${host}.settings.ip} ; ${host}"
|
||||||
|
) roles.default.machines.${host}.settings.services
|
||||||
|
);
|
||||||
|
|
||||||
|
zonefile = pkgs.writeTextFile {
|
||||||
|
name = "db.${settings.tld}";
|
||||||
|
text = ''
|
||||||
|
$TTL 3600
|
||||||
|
@ IN SOA ns.${settings.tld}. admin.${settings.tld}. 1 7200 3600 1209600 3600
|
||||||
|
IN NS ns.${settings.tld}.
|
||||||
|
ns IN A ${settings.ip} ; DNS server
|
||||||
|
|
||||||
|
''
|
||||||
|
+ (lib.strings.concatStringsSep "\n" (
|
||||||
|
map (host: hostServiceEntries host) (lib.attrNames roles.default.machines)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
enable = true;
|
||||||
|
config = ''
|
||||||
|
. {
|
||||||
|
forward . 1.1.1.1
|
||||||
|
cache 30
|
||||||
|
}
|
||||||
|
|
||||||
|
${settings.tld} {
|
||||||
|
file ${zonefile}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
roles.default = {
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
options.services = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = ''
|
||||||
|
Service endpoints this host exposes (without TLD). Each entry will
|
||||||
|
be resolved to <entry>.<tld> using the configured top-level domain.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.ip = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
# TODO: Set a default
|
||||||
|
description = "IP on which the services will listen";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
perInstance =
|
||||||
|
{ roles, ... }:
|
||||||
|
{
|
||||||
|
nixosModule =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
|
||||||
|
networking.nameservers = map (m: "127.0.0.1:5353#${roles.server.machines.${m}.settings.tld}") (
|
||||||
|
lib.attrNames roles.server.machines
|
||||||
|
);
|
||||||
|
|
||||||
|
services.resolved.domains = map (m: "~${roles.server.machines.${m}.settings.tld}") (
|
||||||
|
lib.attrNames roles.server.machines
|
||||||
|
);
|
||||||
|
|
||||||
|
services.unbound = {
|
||||||
|
enable = true;
|
||||||
|
settings = {
|
||||||
|
server = {
|
||||||
|
port = 5353;
|
||||||
|
verbosity = 2;
|
||||||
|
interface = [ "127.0.0.1" ];
|
||||||
|
access-control = [ "127.0.0.0/8 allow" ];
|
||||||
|
do-not-query-localhost = "no";
|
||||||
|
domain-insecure = map (m: "${roles.server.machines.${m}.settings.tld}.") (
|
||||||
|
lib.attrNames roles.server.machines
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
# Default: forward everything else to DHCP-provided resolvers
|
||||||
|
forward-zone = [
|
||||||
|
{
|
||||||
|
name = ".";
|
||||||
|
forward-addr = "127.0.0.53@53"; # Forward to systemd-resolved
|
||||||
|
}
|
||||||
|
];
|
||||||
|
stub-zone = map (m: {
|
||||||
|
name = "${roles.server.machines.${m}.settings.tld}.";
|
||||||
|
stub-addr = "${roles.server.machines.${m}.settings.ip}";
|
||||||
|
}) (lib.attrNames roles.server.machines);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@ let
|
|||||||
module = lib.modules.importApply ./default.nix { };
|
module = lib.modules.importApply ./default.nix { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
clan.modules.state-version = module;
|
clan.modules = {
|
||||||
|
coredns = module;
|
||||||
|
};
|
||||||
perSystem =
|
perSystem =
|
||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
clan.nixosTests.state-version = {
|
clan.nixosTests.coredns = {
|
||||||
imports = [ ./tests/vm/default.nix ];
|
imports = [ ./tests/vm/default.nix ];
|
||||||
|
|
||||||
clan.modules."@clan/state-version" = module;
|
clan.modules."@clan/coredns" = module;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
113
clanServices/coredns/tests/vm/default.nix
Normal file
113
clanServices/coredns/tests/vm/default.nix
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
name = "coredns";
|
||||||
|
|
||||||
|
clan = {
|
||||||
|
directory = ./.;
|
||||||
|
test.useContainers = true;
|
||||||
|
inventory = {
|
||||||
|
|
||||||
|
machines = {
|
||||||
|
dns = { }; # 192.168.1.2
|
||||||
|
server01 = { }; # 192.168.1.3
|
||||||
|
server02 = { }; # 192.168.1.4
|
||||||
|
client = { }; # 192.168.1.1
|
||||||
|
};
|
||||||
|
|
||||||
|
instances = {
|
||||||
|
coredns = {
|
||||||
|
|
||||||
|
module.name = "@clan/coredns";
|
||||||
|
module.input = "self";
|
||||||
|
|
||||||
|
roles.default.tags.all = { };
|
||||||
|
|
||||||
|
# First service
|
||||||
|
roles.default.machines."server01".settings = {
|
||||||
|
ip = "192.168.1.3";
|
||||||
|
services = [ "one" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Second service
|
||||||
|
roles.default.machines."server02".settings = {
|
||||||
|
ip = "192.168.1.4";
|
||||||
|
services = [ "two" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# DNS server
|
||||||
|
roles.server.machines."dns".settings = {
|
||||||
|
ip = "192.168.1.2";
|
||||||
|
tld = "foo";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
dns =
|
||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = [ pkgs.net-tools ];
|
||||||
|
};
|
||||||
|
|
||||||
|
client =
|
||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = [ pkgs.net-tools ];
|
||||||
|
};
|
||||||
|
|
||||||
|
server01 = {
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts."one.foo" = {
|
||||||
|
locations."/" = {
|
||||||
|
return = "200 'test server response one'";
|
||||||
|
extraConfig = "add_header Content-Type text/plain;";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
server02 = {
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts."two.foo" = {
|
||||||
|
locations."/" = {
|
||||||
|
return = "200 'test server response two'";
|
||||||
|
extraConfig = "add_header Content-Type text/plain;";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
import json
|
||||||
|
start_all()
|
||||||
|
|
||||||
|
machines = [server01, server02, dns, client]
|
||||||
|
|
||||||
|
for m in machines:
|
||||||
|
m.systemctl("start network-online.target")
|
||||||
|
|
||||||
|
for m in machines:
|
||||||
|
m.wait_for_unit("network-online.target")
|
||||||
|
|
||||||
|
# import time
|
||||||
|
# time.sleep(2333333)
|
||||||
|
|
||||||
|
# This should work, but is borken in tests i think? Instead we dig directly
|
||||||
|
|
||||||
|
# client.succeed("curl -k -v http://one.foo")
|
||||||
|
# client.succeed("curl -k -v http://two.foo")
|
||||||
|
|
||||||
|
answer = client.succeed("dig @192.168.1.2 one.foo")
|
||||||
|
assert "192.168.1.3" in answer, "IP not found"
|
||||||
|
|
||||||
|
answer = client.succeed("dig @192.168.1.2 two.foo")
|
||||||
|
assert "192.168.1.4" in answer, "IP not found"
|
||||||
|
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
This service generates the `system.stateVersion` of the nixos installation
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
Possible values:
|
|
||||||
[system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
The following configuration will set `stateVersion` for all machines:
|
|
||||||
|
|
||||||
```
|
|
||||||
inventory.instances = {
|
|
||||||
state-version = {
|
|
||||||
module = {
|
|
||||||
name = "state-version";
|
|
||||||
input = "clan";
|
|
||||||
};
|
|
||||||
roles.default.tags.all = { };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
If you are already setting `system.stateVersion`, either let the automatic
|
|
||||||
generation happen, or trigger the generation manually for the machine. The
|
|
||||||
service will take the specified version, if one is already supplied through the
|
|
||||||
config.
|
|
||||||
|
|
||||||
To manually generate the version for a specified machine run:
|
|
||||||
|
|
||||||
```
|
|
||||||
clan vars generate [MACHINE]
|
|
||||||
```
|
|
||||||
|
|
||||||
If the setting was already set, you can then remove `system.stateVersion` from
|
|
||||||
your machine configuration. For new machines, just import the service as shown
|
|
||||||
above.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{ ... }:
|
|
||||||
{
|
|
||||||
_class = "clan.service";
|
|
||||||
manifest.name = "clan-core/state-version";
|
|
||||||
manifest.description = "Automatically generate the state version of the nixos installation.";
|
|
||||||
manifest.categories = [ "System" ];
|
|
||||||
manifest.readme = builtins.readFile ./README.md;
|
|
||||||
|
|
||||||
roles.default = {
|
|
||||||
|
|
||||||
perInstance =
|
|
||||||
{ ... }:
|
|
||||||
{
|
|
||||||
nixosModule =
|
|
||||||
{
|
|
||||||
config,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
var = config.clan.core.vars.generators.state-version.files.version or { };
|
|
||||||
in
|
|
||||||
{
|
|
||||||
|
|
||||||
warnings = [
|
|
||||||
''
|
|
||||||
The clan.state-version service is deprecated and will be
|
|
||||||
removed on 2025-07-15 in favor of a nix option.
|
|
||||||
|
|
||||||
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
|
||||||
|
|
||||||
clan.core.vars.generators.state-version = {
|
|
||||||
files.version = {
|
|
||||||
secret = false;
|
|
||||||
value = lib.mkDefault config.system.nixos.release;
|
|
||||||
};
|
|
||||||
runtimeInputs = [ ];
|
|
||||||
script = ''
|
|
||||||
echo -n ${config.system.stateVersion} > "$out"/version
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{ lib, ... }:
|
|
||||||
{
|
|
||||||
name = "service-state-version";
|
|
||||||
|
|
||||||
clan = {
|
|
||||||
directory = ./.;
|
|
||||||
inventory = {
|
|
||||||
machines.server = { };
|
|
||||||
instances.default = {
|
|
||||||
module.name = "@clan/state-version";
|
|
||||||
module.input = "self";
|
|
||||||
roles.default.machines."server" = { };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes.server = { };
|
|
||||||
|
|
||||||
testScript = lib.mkDefault ''
|
|
||||||
start_all()
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
25.11
|
|
||||||
@@ -12,6 +12,11 @@ import ipaddress
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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:
|
def hash_string(s: str) -> str:
|
||||||
"""Generate SHA256 hash of string."""
|
"""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 = f"fd{prefix_bits:08x}"
|
||||||
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
|
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
|
||||||
|
|
||||||
network = ipaddress.IPv6Network(prefix_formatted)
|
return ipaddress.IPv6Network(prefix_formatted)
|
||||||
return network
|
|
||||||
|
|
||||||
|
|
||||||
def generate_controller_subnet(
|
def generate_controller_subnet(
|
||||||
@@ -60,9 +64,7 @@ def generate_controller_subnet(
|
|||||||
# The controller subnet is at base_prefix:controller_id::/56
|
# The controller subnet is at base_prefix:controller_id::/56
|
||||||
base_int = int(base_network.network_address)
|
base_int = int(base_network.network_address)
|
||||||
controller_subnet_int = base_int | (controller_id << (128 - 56))
|
controller_subnet_int = base_int | (controller_id << (128 - 56))
|
||||||
controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
|
return ipaddress.IPv6Network((controller_subnet_int, 56))
|
||||||
|
|
||||||
return controller_subnet
|
|
||||||
|
|
||||||
|
|
||||||
def generate_peer_suffix(peer_name: str) -> str:
|
def generate_peer_suffix(peer_name: str) -> str:
|
||||||
@@ -76,12 +78,11 @@ def generate_peer_suffix(peer_name: str) -> str:
|
|||||||
suffix_bits = h[:16]
|
suffix_bits = h[:16]
|
||||||
|
|
||||||
# Format as IPv6 suffix without leading colon
|
# 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 f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
|
||||||
return suffix
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < MIN_ARGS_BASE:
|
||||||
print(
|
print(
|
||||||
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>",
|
"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)
|
base_network = generate_ula_prefix(instance_name)
|
||||||
|
|
||||||
if node_type == "controller":
|
if node_type == "controller":
|
||||||
if len(sys.argv) < 5:
|
if len(sys.argv) < MIN_ARGS_CONTROLLER:
|
||||||
print("Controller name required")
|
print("Controller name required")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ def main() -> None:
|
|||||||
(output_dir / "prefix").write_text(prefix_str)
|
(output_dir / "prefix").write_text(prefix_str)
|
||||||
|
|
||||||
elif node_type == "peer":
|
elif node_type == "peer":
|
||||||
if len(sys.argv) < 5:
|
if len(sys.argv) < MIN_ARGS_PEER:
|
||||||
print("Peer name required")
|
print("Peer name required")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
18
devFlake/flake.lock
generated
18
devFlake/flake.lock
generated
@@ -3,10 +3,10 @@
|
|||||||
"clan-core-for-checks": {
|
"clan-core-for-checks": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756133826,
|
"lastModified": 1756166884,
|
||||||
"narHash": "sha256-In3u7UVSjPzX9u4Af9K/jVy4MMAZBzxByMe4GREpHBo=",
|
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"rev": "c4da43da0f583bd3cbcfd1f3acf74f9dc51b8fdd",
|
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
|
||||||
"shallow": true,
|
"shallow": true,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.clan.lol/clan/clan-core"
|
"url": "https://git.clan.lol/clan/clan-core"
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-dev": {
|
"nixpkgs-dev": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756104823,
|
"lastModified": 1756662818,
|
||||||
"narHash": "sha256-wRzHREXDOrbCjy+sqo4t3JoInbB2PuhXIUa8NWdh9tk=",
|
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d7967bed5381e65208f4fb8d5502e3c36bb94759",
|
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -165,11 +165,11 @@
|
|||||||
"nixpkgs": []
|
"nixpkgs": []
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755934250,
|
"lastModified": 1756662192,
|
||||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
config,
|
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
|
||||||
mirrorBoot = idx: {
|
mirrorBoot = idx: {
|
||||||
# suffix is to prevent disk name collisions
|
# suffix is to prevent disk name collisions
|
||||||
name = idx + suffix;
|
name = idx;
|
||||||
type = "disk";
|
type = "disk";
|
||||||
device = "/dev/disk/by-id/${idx}";
|
device = "/dev/disk/by-id/${idx}";
|
||||||
content = {
|
content = {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
config,
|
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
|
||||||
mirrorBoot = idx: {
|
mirrorBoot = idx: {
|
||||||
# suffix is to prevent disk name collisions
|
# suffix is to prevent disk name collisions
|
||||||
name = idx + suffix;
|
name = idx;
|
||||||
type = "disk";
|
type = "disk";
|
||||||
device = "/dev/disk/by-id/${idx}";
|
device = "/dev/disk/by-id/${idx}";
|
||||||
content = {
|
content = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ site_name: Clan Documentation
|
|||||||
site_url: https://docs.clan.lol
|
site_url: https://docs.clan.lol
|
||||||
repo_url: https://git.clan.lol/clan/clan-core/
|
repo_url: https://git.clan.lol/clan/clan-core/
|
||||||
repo_name: "_>"
|
repo_name: "_>"
|
||||||
edit_uri: _edit/main/docs/docs/
|
edit_uri: _edit/main/docs/site/
|
||||||
|
|
||||||
validation:
|
validation:
|
||||||
omitted_files: warn
|
omitted_files: warn
|
||||||
@@ -94,6 +94,7 @@ nav:
|
|||||||
- reference/clanServices/index.md
|
- reference/clanServices/index.md
|
||||||
- reference/clanServices/admin.md
|
- reference/clanServices/admin.md
|
||||||
- reference/clanServices/borgbackup.md
|
- reference/clanServices/borgbackup.md
|
||||||
|
- reference/clanServices/coredns.md
|
||||||
- reference/clanServices/data-mesher.md
|
- reference/clanServices/data-mesher.md
|
||||||
- reference/clanServices/dyndns.md
|
- reference/clanServices/dyndns.md
|
||||||
- reference/clanServices/emergency-access.md
|
- reference/clanServices/emergency-access.md
|
||||||
@@ -106,7 +107,6 @@ nav:
|
|||||||
- reference/clanServices/monitoring.md
|
- reference/clanServices/monitoring.md
|
||||||
- reference/clanServices/packages.md
|
- reference/clanServices/packages.md
|
||||||
- reference/clanServices/sshd.md
|
- reference/clanServices/sshd.md
|
||||||
- reference/clanServices/state-version.md
|
|
||||||
- reference/clanServices/syncthing.md
|
- reference/clanServices/syncthing.md
|
||||||
- reference/clanServices/trusted-nix-caches.md
|
- reference/clanServices/trusted-nix-caches.md
|
||||||
- reference/clanServices/users.md
|
- reference/clanServices/users.md
|
||||||
@@ -173,6 +173,7 @@ theme:
|
|||||||
- content.code.annotate
|
- content.code.annotate
|
||||||
- content.code.copy
|
- content.code.copy
|
||||||
- content.tabs.link
|
- content.tabs.link
|
||||||
|
- content.action.edit
|
||||||
icon:
|
icon:
|
||||||
repo: fontawesome/brands/git
|
repo: fontawesome/brands/git
|
||||||
custom_dir: overrides
|
custom_dir: overrides
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
|
|||||||
|
|
||||||
CLAN_MODULES_VIA_SERVICE = os.environ.get("CLAN_MODULES_VIA_SERVICE")
|
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:
|
def sanitize(text: str) -> str:
|
||||||
@@ -551,8 +551,7 @@ def options_docs_from_tree(
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
md = render_tree(root)
|
return render_tree(root)
|
||||||
return md
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
## 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:
|
For example:
|
||||||
|
|
||||||
@@ -18,116 +24,135 @@ For example:
|
|||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup = {
|
borgbackup = {
|
||||||
roles.client.machines."laptop" = {};
|
roles.client.machines."laptop" = {};
|
||||||
roles.client.machines."server1" = {};
|
roles.client.machines."workstation" = {};
|
||||||
|
|
||||||
roles.server.machines."backup-box" = {};
|
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
|
## 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.
|
Each instance includes a reference to a **module specification** -- this is how
|
||||||
Usually one would just use `imports` but we needd to make the `module source` configurable via Python API.
|
Clan knows which service module to use and where it came from.
|
||||||
By default it is not required to specify the `module`, in which case it defaults to the preprovided services of clan-core.
|
|
||||||
|
|
||||||
---
|
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
|
||||||
## Override Example
|
`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`:
|
Example of instantiating a `borgbackup` service using `clan-core`:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
# Instance Name: Different name for this 'borgbackup' instance
|
|
||||||
borgbackup = {
|
borgbackup = { # <- Instance name
|
||||||
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
|
|
||||||
module = {
|
# This can be partially/fully specified,
|
||||||
name = "borgbackup"; # <-- Name of the module (optional)
|
# - If the instance name is not the name of the module
|
||||||
input = "clan-core"; # <-- The flake input where the service is defined (optional)
|
# - 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
|
# 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.client.machines."machine-a" = {};
|
||||||
roles.server.machines."backup-host" = {};
|
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
|
```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 = {
|
inventory.instances = {
|
||||||
# instance name is `borgbackup` -> clan core module `borgbackup` will be loaded.
|
|
||||||
borgbackup = {
|
borgbackup = {
|
||||||
# Participation of the machines is defined via roles
|
# Settings for 'machine-a'
|
||||||
# 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";
|
|
||||||
};
|
|
||||||
roles.client.machines."machine-a" = {
|
roles.client.machines."machine-a" = {
|
||||||
# 'client' -Settings of 'machine-a'
|
|
||||||
settings = {
|
settings = {
|
||||||
backupFolders = [
|
backupFolders = [
|
||||||
/home
|
/home
|
||||||
/var
|
/var
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
# ---------------------------
|
|
||||||
};
|
};
|
||||||
roles.server.machines."backup-host" = {};
|
|
||||||
|
# Settings for all machines of the role "server"
|
||||||
|
roles.server.settings = {
|
||||||
|
directory = "/var/lib/borgbackup";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tags
|
## 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
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup-example = {
|
borgbackup = {
|
||||||
module = {
|
# "All" machines are assigned to the borgbackup 'client' role
|
||||||
name = "borgbackup";
|
roles.client.tags = [ "all" ];
|
||||||
input = "clan-core";
|
|
||||||
};
|
# But only one specific machine (backup-host) is assigned to the 'server' role
|
||||||
#
|
|
||||||
# The 'all' -tag targets all machines
|
|
||||||
roles.client.tags."all" = {};
|
|
||||||
# ---------------------------
|
|
||||||
roles.server.machines."backup-host" = {};
|
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
|
## Picking a clanService
|
||||||
|
|
||||||
You can use services exposed by Clan's core module library, `clan-core`.
|
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.
|
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
|
## 💡 Tips for Working with clanServices
|
||||||
|
|
||||||
* You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
|
- 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.
|
- 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.
|
- Roles can target different machines or be scoped dynamically.
|
||||||
|
|
||||||
---
|
______________________________________________________________________
|
||||||
|
|
||||||
## What's Next?
|
## What's Next?
|
||||||
|
|
||||||
* [Author your own clanService →](../guides/services/community.md)
|
- [Author your own clanService →](../guides/services/community.md)
|
||||||
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
|
- [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
|
||||||
|
|
||||||
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->
|
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
|
||||||
# Update Your Machines
|
# Update Machines
|
||||||
|
|
||||||
Clan CLI enables you to remotely update your machines over SSH. This requires setting up a target address for each target machine.
|
The Clan command line interface enables you to update machines remotely over SSH.
|
||||||
|
In this guide we will teach you how to set a `targetHost` in Nix,
|
||||||
|
and how to define a remote builder for your machine closures.
|
||||||
|
|
||||||
### Setting `targetHost`
|
|
||||||
|
|
||||||
In your Nix files, set the `targetHost` to the reachable IP address of your new machine. This eliminates the need to specify `--target-host` with every command.
|
## Setting `targetHost`
|
||||||
|
|
||||||
|
Set the machine’s `targetHost` to the reachable IP address of the new machine.
|
||||||
|
This eliminates the need to specify `--target-host` in CLI commands.
|
||||||
|
|
||||||
```{.nix title="clan.nix" hl_lines="9"}
|
```{.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.
|
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.
|
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,
|
## Updating Machine Configurations
|
||||||
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.
|
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}
|
```{.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
|
```bash
|
||||||
# Build on a remote host
|
# Build on a remote host
|
||||||
@@ -56,23 +90,9 @@ clan machines update jon --build-host local
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
Make sure that the CPU architecture is the same for the buildHost as for the targetHost.
|
Make sure the CPU architecture of the `buildHost` matches that of 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.
|
|
||||||
|
|
||||||
### Updating Machine Configurations
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Excluding a machine from `clan machine update`
|
### 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
|
### Uploading Flake Inputs
|
||||||
|
|
||||||
When updating remote machines, flake inputs are usually fetched by the build host.
|
When updating remote machines, flake inputs are usually fetched by the build host.
|
||||||
However, if your flake inputs require authentication (e.g., private repositories),
|
However, if flake inputs require authentication (e.g., private repositories),
|
||||||
you can use the `--upload-inputs` flag to upload all inputs from your local machine:
|
|
||||||
|
Use the `--upload-inputs` flag to upload all inputs from your local machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clan machines update jon --upload-inputs
|
clan machines update jon --upload-inputs
|
||||||
```
|
```
|
||||||
|
|
||||||
This is particularly useful when:
|
This is particularly useful when:
|
||||||
- Your flake references private Git repositories
|
- The flake references private Git repositories
|
||||||
- Authentication credentials are only available on your local machine
|
- Authentication credentials are only available on local machine
|
||||||
- The build host doesn't have access to certain network resources
|
- 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) | |
|
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
|
||||||
| `deltachat` | ❌ Removed | |
|
| `deltachat` | ❌ Removed | |
|
||||||
| `disk-id` | ❌ Removed | |
|
| `disk-id` | ❌ Removed | |
|
||||||
| `dyndns` | [Being Migrated](https://git.clan.lol/clan/clan-core/pulls/4390) | |
|
| `dyndns` | ✅ [Migrated](../../reference/clanServices/dyndns.md) | |
|
||||||
| `ergochat` | ❌ Removed | |
|
| `ergochat` | ❌ Removed | |
|
||||||
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
|
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
|
||||||
| `golem-provider` | ❌ Removed | |
|
| `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 |
|
| `iwd` | ❌ Removed | Use [wifi service](../../reference/clanServices/wifi.md) instead |
|
||||||
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
|
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
|
||||||
| `localsend` | ❌ Removed | |
|
| `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) | |
|
| `matrix-synapse` | ✅ [Migrated](../../reference/clanServices/matrix-synapse.md) | |
|
||||||
| `moonlight` | ❌ Removed | |
|
| `moonlight` | ❌ Removed | |
|
||||||
| `mumble` | ❌ Removed | |
|
| `mumble` | ❌ Removed | |
|
||||||
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
|
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
|
||||||
| `nginx` | ❌ Removed | |
|
| `nginx` | ❌ Removed | |
|
||||||
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
|
| `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) |
|
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | See [migration guide](../../reference/clanServices/users.md#migration-from-root-password-module) |
|
||||||
| `single-disk` | ❌ Removed | |
|
| `single-disk` | ❌ Removed | |
|
||||||
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
|
| `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 | |
|
| `static-hosts` | ❌ Removed | |
|
||||||
| `sunshine` | ❌ Removed | |
|
| `sunshine` | ❌ Removed | |
|
||||||
| `syncthing-static-peers` | ❌ 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)
|
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
|
||||||
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
|
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
|
||||||
|
|||||||
20
flake.lock
generated
20
flake.lock
generated
@@ -13,11 +13,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756091210,
|
"lastModified": 1756695982,
|
||||||
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
|
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=",
|
||||||
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
|
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb",
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/eb831bca21476fa8f6df26cb39e076842634700d.tar.gz"
|
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/cc8f26e7e6c2dc985526ba59b286ae5a83168cdb.tar.gz"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -99,11 +99,11 @@
|
|||||||
},
|
},
|
||||||
"nixos-facter-modules": {
|
"nixos-facter-modules": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756109073,
|
"lastModified": 1756491981,
|
||||||
"narHash": "sha256-5pjFEziluVwJ0Z50h9laKfWbDluXuA5ada05xb/QiV4=",
|
"narHash": "sha256-lXyDAWPw/UngVtQfgQ8/nrubs2r+waGEYIba5UX62+k=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixos-facter-modules",
|
"repo": "nixos-facter-modules",
|
||||||
"rev": "a1042c81126d9c9314c1eb1a7b89ab4d81b5dea7",
|
"rev": "c1b29520945d3e148cd96618c8a0d1f850965d8c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755934250,
|
"lastModified": 1756662192,
|
||||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ in
|
|||||||
in
|
in
|
||||||
{ config, ... }:
|
{ config, ... }:
|
||||||
{
|
{
|
||||||
|
staticModules = clan-core.clan.modules;
|
||||||
|
|
||||||
distributedServices = clanLib.inventory.mapInstances {
|
distributedServices = clanLib.inventory.mapInstances {
|
||||||
inherit (clanConfig) inventory exportsModule;
|
inherit (clanConfig) inventory exportsModule;
|
||||||
inherit flakeInputs directory;
|
inherit flakeInputs directory;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ let
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
options.staticModules = lib.mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
type = lib.types.raw;
|
||||||
|
|
||||||
|
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
|
||||||
|
};
|
||||||
options.modulesPerSource = lib.mkOption {
|
options.modulesPerSource = lib.mkOption {
|
||||||
# { sourceName :: { moduleName :: {} }}
|
# { sourceName :: { moduleName :: {} }}
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|||||||
@@ -324,14 +324,13 @@ class Machine:
|
|||||||
# Always run command with shell opts
|
# Always run command with shell opts
|
||||||
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
|
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
|
||||||
|
|
||||||
proc = subprocess.run(
|
return subprocess.run(
|
||||||
self.nsenter_command(command),
|
self.nsenter_command(command),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
return proc
|
|
||||||
|
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -180,15 +180,15 @@ class CompositeLogger(AbstractLogger):
|
|||||||
stack.enter_context(logger.nested(message, attributes))
|
stack.enter_context(logger.nested(message, attributes))
|
||||||
yield
|
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:
|
for logger in self.logger_list:
|
||||||
logger.info(*args, **kwargs)
|
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:
|
for logger in self.logger_list:
|
||||||
logger.warning(*args, **kwargs)
|
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:
|
for logger in self.logger_list:
|
||||||
logger.error(*args, **kwargs)
|
logger.error(*args, **kwargs)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -245,13 +245,13 @@ class TerminalLogger(AbstractLogger):
|
|||||||
toc = time.time()
|
toc = time.time()
|
||||||
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
|
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)
|
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)
|
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)
|
self.log(*args, **kwargs)
|
||||||
|
|
||||||
def print_serial_logs(self, enable: bool) -> None:
|
def print_serial_logs(self, enable: bool) -> None:
|
||||||
@@ -297,13 +297,13 @@ class XMLLogger(AbstractLogger):
|
|||||||
self.xml.characters(message)
|
self.xml.characters(message)
|
||||||
self.xml.endElement("line")
|
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)
|
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)
|
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)
|
self.log(*args, **kwargs)
|
||||||
|
|
||||||
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
{
|
{
|
||||||
imports = lib.optional (_class == "nixos") (
|
imports = lib.optional (_class == "nixos") (
|
||||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||||
|
|
||||||
|
# Enable automatic state-version generation.
|
||||||
|
clan.core.settings.state-version.enable = lib.mkDefault true;
|
||||||
|
|
||||||
# Use systemd during boot as well except:
|
# Use systemd during boot as well except:
|
||||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
# - 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
|
# - 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 {
|
config = lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||||
|
|
||||||
# This disables the HTML manual and `nixos-help` command but leaves
|
# This disables the HTML manual and `nixos-help` command but leaves
|
||||||
# `man configuration.nix`
|
# `man configuration.nix`
|
||||||
documentation.doc.enable = lib.mkDefault false;
|
documentation.doc.enable = lib.mkDefault false;
|
||||||
|
|||||||
@@ -9,28 +9,11 @@
|
|||||||
|
|
||||||
clan = {
|
clan = {
|
||||||
directory = ./.;
|
directory = ./.;
|
||||||
|
machines.server = {
|
||||||
# Workaround until we can use nodes.server = { };
|
clan.core.settings.state-version.enable = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# TODO: Broken. Use instead of importer after fixing.
|
|
||||||
# nodes.server = { };
|
|
||||||
|
|
||||||
# This is not an actual vm test, this is a workaround to
|
# This is not an actual vm test, this is a workaround to
|
||||||
# generate the needed vars for the eval test.
|
# generate the needed vars for the eval test.
|
||||||
testScript = "";
|
testScript = "";
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
NODE_ID_LENGTH = 10
|
||||||
|
NETWORK_ID_LENGTH = 16
|
||||||
|
|
||||||
|
|
||||||
class ClanError(Exception):
|
class ClanError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -55,8 +59,8 @@ class Identity:
|
|||||||
|
|
||||||
def node_id(self) -> str:
|
def node_id(self) -> str:
|
||||||
nid = self.public.split(":")[0]
|
nid = self.public.split(":")[0]
|
||||||
if len(nid) != 10:
|
if len(nid) != NODE_ID_LENGTH:
|
||||||
msg = f"node_id must be 10 characters long, got {len(nid)}: {nid}"
|
msg = f"node_id must be {NODE_ID_LENGTH} characters long, got {len(nid)}: {nid}"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
return nid
|
return nid
|
||||||
|
|
||||||
@@ -173,8 +177,8 @@ def create_identity() -> Identity:
|
|||||||
|
|
||||||
|
|
||||||
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
||||||
if len(network_id) != 16:
|
if len(network_id) != NETWORK_ID_LENGTH:
|
||||||
msg = f"network_id must be 16 characters long, got '{network_id}'"
|
msg = f"network_id must be {NETWORK_ID_LENGTH} characters long, got '{network_id}'"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
nwid = int(network_id, 16)
|
nwid = int(network_id, 16)
|
||||||
node_id = int(identity.node_id(), 16)
|
node_id = int(identity.node_id(), 16)
|
||||||
|
|||||||
5
nixosModules/clanCore/zerotier/genmoon.py
Normal file → Executable file
5
nixosModules/clanCore/zerotier/genmoon.py
Normal file → Executable file
@@ -6,9 +6,12 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
REQUIRED_ARGS = 4
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if len(sys.argv) != 4:
|
if len(sys.argv) != REQUIRED_ARGS:
|
||||||
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
|
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
moon_json_path = sys.argv[1]
|
moon_json_path = sys.argv[1]
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ let
|
|||||||
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
||||||
&& (builtins.tryEval kernelPackages).success
|
&& (builtins.tryEval kernelPackages).success
|
||||||
&& (
|
&& (
|
||||||
(!isUnstable && !kernelPackages.zfs.meta.broken)
|
let
|
||||||
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
|
zfsPackage =
|
||||||
|
if isUnstable then
|
||||||
|
kernelPackages.zfs_unstable
|
||||||
|
else
|
||||||
|
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
|
||||||
|
in
|
||||||
|
!(zfsPackage.meta.broken or false)
|
||||||
)
|
)
|
||||||
) pkgs.linuxKernel.packages;
|
) pkgs.linuxKernel.packages;
|
||||||
latestKernelPackage = lib.last (
|
latestKernelPackage = lib.last (
|
||||||
@@ -24,5 +30,5 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Note this might jump back and worth as kernel get added or removed.
|
# Note this might jump back and worth as kernel get added or removed.
|
||||||
boot.kernelPackages = latestKernelPackage;
|
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from contextlib import ExitStack
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
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.api.tasks import WebThread
|
||||||
from clan_lib.async_run import set_current_thread_opkey, set_should_cancel
|
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:
|
def process_request(self, request: BackendRequest) -> None:
|
||||||
"""Process an API request through the middleware chain."""
|
"""Process an API request through the middleware chain."""
|
||||||
from .middleware import MiddlewareContext
|
from .middleware import MiddlewareContext # noqa: PLC0415
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
context = MiddlewareContext(
|
context = MiddlewareContext(
|
||||||
@@ -59,7 +59,7 @@ class ApiBridge(ABC):
|
|||||||
f"{middleware.__class__.__name__} => {request.method_name}",
|
f"{middleware.__class__.__name__} => {request.method_name}",
|
||||||
)
|
)
|
||||||
middleware.process(context)
|
middleware.process(context)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
# If middleware fails, handle error
|
# If middleware fails, handle error
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
request.op_key or "unknown",
|
request.op_key or "unknown",
|
||||||
@@ -75,8 +75,6 @@ class ApiBridge(ABC):
|
|||||||
location: list[str],
|
location: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send an error response."""
|
"""Send an error response."""
|
||||||
from clan_lib.api import ApiError, ErrorDataClass
|
|
||||||
|
|
||||||
error_data = ErrorDataClass(
|
error_data = ErrorDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key,
|
||||||
status="error",
|
status="error",
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ def get_system_file(
|
|||||||
|
|
||||||
def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
||||||
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
|
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
|
||||||
global RESULT
|
|
||||||
RESULT[op_key] = data
|
RESULT[op_key] = data
|
||||||
|
|
||||||
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||||
|
|||||||
@@ -94,10 +94,10 @@ class LoggingMiddleware(Middleware):
|
|||||||
if self.handler:
|
if self.handler:
|
||||||
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
||||||
self.handler.new_handler.close()
|
self.handler.new_handler.close()
|
||||||
if self.log_f:
|
|
||||||
self.log_f.close()
|
|
||||||
if self.original_ctx:
|
if self.original_ctx:
|
||||||
set_async_ctx(self.original_ctx)
|
set_async_ctx(self.original_ctx)
|
||||||
|
if self.log_f:
|
||||||
|
self.log_f.close()
|
||||||
|
|
||||||
# Register the logging context manager
|
# Register the logging context manager
|
||||||
self.register_context_manager(context, LoggingContextManager(log_file))
|
self.register_context_manager(context, LoggingContextManager(log_file))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from clan_app.api.middleware import (
|
|||||||
LoggingMiddleware,
|
LoggingMiddleware,
|
||||||
MethodExecutionMiddleware,
|
MethodExecutionMiddleware,
|
||||||
)
|
)
|
||||||
|
from clan_app.deps.http.http_server import HttpApiServer
|
||||||
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -64,8 +66,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
# Start HTTP API server if requested
|
# Start HTTP API server if requested
|
||||||
http_server = None
|
http_server = None
|
||||||
if app_opts.http_api:
|
if app_opts.http_api:
|
||||||
from clan_app.deps.http.http_server import HttpApiServer
|
|
||||||
|
|
||||||
openapi_file = os.getenv("OPENAPI_FILE", None)
|
openapi_file = os.getenv("OPENAPI_FILE", None)
|
||||||
swagger_dist = os.getenv("SWAGGER_UI_DIST", 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")
|
log.info("Press Ctrl+C to stop the server")
|
||||||
try:
|
try:
|
||||||
# Keep the main thread alive
|
# Keep the main thread alive
|
||||||
import time
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", content_type)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(file_data)
|
self.wfile.write(file_data)
|
||||||
except Exception as e:
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
log.error(f"Error reading Swagger file: {e!s}")
|
log.exception("Error reading Swagger file")
|
||||||
self.send_error(500, "Internal Server Error")
|
self.send_error(500, "Internal Server Error")
|
||||||
|
|
||||||
def _get_swagger_file_path(self, rel_path: str) -> Path:
|
def _get_swagger_file_path(self, rel_path: str) -> Path:
|
||||||
@@ -191,13 +191,13 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
return file_data
|
return file_data
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None: # noqa: N802
|
def do_OPTIONS(self) -> None:
|
||||||
"""Handle CORS preflight requests."""
|
"""Handle CORS preflight requests."""
|
||||||
self.send_response_only(200)
|
self.send_response_only(200)
|
||||||
self._send_cors_headers()
|
self._send_cors_headers()
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
def do_GET(self) -> None:
|
||||||
"""Handle GET requests."""
|
"""Handle GET requests."""
|
||||||
parsed_url = urlparse(self.path)
|
parsed_url = urlparse(self.path)
|
||||||
path = parsed_url.path
|
path = parsed_url.path
|
||||||
@@ -211,7 +211,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
|
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."""
|
"""Handle POST requests."""
|
||||||
parsed_url = urlparse(self.path)
|
parsed_url = urlparse(self.path)
|
||||||
path = parsed_url.path
|
path = parsed_url.path
|
||||||
@@ -252,7 +252,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
gen_op_key = str(uuid.uuid4())
|
gen_op_key = str(uuid.uuid4())
|
||||||
try:
|
try:
|
||||||
self._handle_api_request(method_name, request_data, gen_op_key)
|
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}")
|
log.exception(f"Error processing API request {method_name}")
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
gen_op_key,
|
gen_op_key,
|
||||||
@@ -264,10 +264,10 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
"""Read and parse the request body. Returns None if there was an error."""
|
"""Read and parse the request body. Returns None if there was an error."""
|
||||||
try:
|
try:
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
if content_length > 0:
|
if content_length == 0:
|
||||||
body = self.rfile.read(content_length)
|
return {}
|
||||||
return json.loads(body.decode("utf-8"))
|
body = self.rfile.read(content_length)
|
||||||
return {}
|
return json.loads(body.decode("utf-8"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post",
|
||||||
@@ -275,7 +275,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
["http_bridge", "POST", method_name],
|
["http_bridge", "POST", method_name],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except (OSError, ValueError, UnicodeDecodeError) as e:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post",
|
||||||
f"Error reading request: {e!s}",
|
f"Error reading request: {e!s}",
|
||||||
@@ -305,7 +305,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
op_key=op_key,
|
op_key=op_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (KeyError, TypeError, ValueError) as e:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
gen_op_key,
|
gen_op_key,
|
||||||
str(e),
|
str(e),
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from unittest.mock import Mock
|
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_lib.api import MethodRegistry, tasks
|
from clan_lib.api import MethodRegistry, tasks
|
||||||
from clan_lib.async_run import is_async_cancelled
|
from clan_lib.async_run import is_async_cancelled
|
||||||
from clan_lib.log_manager import LogManager
|
|
||||||
|
|
||||||
from clan_app.api.middleware import (
|
from clan_app.api.middleware import (
|
||||||
ArgumentParsingMiddleware,
|
ArgumentParsingMiddleware,
|
||||||
@@ -53,31 +51,20 @@ def mock_api() -> MethodRegistry:
|
|||||||
return api
|
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
|
@pytest.fixture
|
||||||
def http_bridge(
|
def http_bridge(
|
||||||
mock_api: MethodRegistry,
|
mock_api: MethodRegistry,
|
||||||
mock_log_manager: Mock,
|
|
||||||
) -> tuple[MethodRegistry, tuple]:
|
) -> tuple[MethodRegistry, tuple]:
|
||||||
"""Create HTTP bridge dependencies for testing."""
|
"""Create HTTP bridge dependencies for testing."""
|
||||||
middleware_chain = (
|
middleware_chain = (
|
||||||
ArgumentParsingMiddleware(api=mock_api),
|
ArgumentParsingMiddleware(api=mock_api),
|
||||||
# LoggingMiddleware(log_manager=mock_log_manager),
|
|
||||||
MethodExecutionMiddleware(api=mock_api),
|
MethodExecutionMiddleware(api=mock_api),
|
||||||
)
|
)
|
||||||
return mock_api, middleware_chain
|
return mock_api, middleware_chain
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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."""
|
"""Create HTTP server with mock dependencies."""
|
||||||
server = HttpApiServer(
|
server = HttpApiServer(
|
||||||
api=mock_api,
|
api=mock_api,
|
||||||
@@ -87,7 +74,6 @@ def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServ
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# 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
|
# The actual HTTP handling will be tested through the server integration tests
|
||||||
assert len(middleware_chain) == 2
|
assert len(middleware_chain) == 2
|
||||||
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
||||||
# assert isinstance(middleware_chain[1], LoggingMiddleware)
|
|
||||||
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,14 +136,14 @@ class TestHttpApiServer:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Test root endpoint
|
# Test root endpoint
|
||||||
response = urlopen("http://127.0.0.1:8081/") # noqa: S310
|
response = urlopen("http://127.0.0.1:8081/")
|
||||||
data: dict = json.loads(response.read().decode())
|
data: dict = json.loads(response.read().decode())
|
||||||
assert data["body"]["status"] == "success"
|
assert data["body"]["status"] == "success"
|
||||||
assert data["body"]["data"]["message"] == "Clan API Server"
|
assert data["body"]["data"]["message"] == "Clan API Server"
|
||||||
assert data["body"]["data"]["version"] == "1.0.0"
|
assert data["body"]["data"]["version"] == "1.0.0"
|
||||||
|
|
||||||
# Test methods endpoint
|
# Test methods endpoint
|
||||||
response = urlopen("http://127.0.0.1:8081/api/methods") # noqa: S310
|
response = urlopen("http://127.0.0.1:8081/api/methods")
|
||||||
data = json.loads(response.read().decode())
|
data = json.loads(response.read().decode())
|
||||||
assert data["body"]["status"] == "success"
|
assert data["body"]["status"] == "success"
|
||||||
assert "test_method" in data["body"]["data"]["methods"]
|
assert "test_method" in data["body"]["data"]["methods"]
|
||||||
@@ -194,7 +179,7 @@ class TestHttpApiServer:
|
|||||||
try:
|
try:
|
||||||
# Test 404 error
|
# Test 404 error
|
||||||
|
|
||||||
res = urlopen("http://127.0.0.1:8081/nonexistent") # noqa: S310
|
res = urlopen("http://127.0.0.1:8081/nonexistent")
|
||||||
assert res.status == 200
|
assert res.status == 200
|
||||||
body = json.loads(res.read().decode())["body"]
|
body = json.loads(res.read().decode())["body"]
|
||||||
assert body["status"] == "error"
|
assert body["status"] == "error"
|
||||||
@@ -259,7 +244,6 @@ class TestIntegration:
|
|||||||
def test_full_request_flow(
|
def test_full_request_flow(
|
||||||
self,
|
self,
|
||||||
mock_api: MethodRegistry,
|
mock_api: MethodRegistry,
|
||||||
mock_log_manager: Mock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test complete request flow from server to bridge to middleware."""
|
"""Test complete request flow from server to bridge to middleware."""
|
||||||
server: HttpApiServer = HttpApiServer(
|
server: HttpApiServer = HttpApiServer(
|
||||||
@@ -270,7 +254,6 @@ class TestIntegration:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# Bridge will be created automatically when accessed
|
||||||
@@ -306,7 +289,6 @@ class TestIntegration:
|
|||||||
def test_blocking_task(
|
def test_blocking_task(
|
||||||
self,
|
self,
|
||||||
mock_api: MethodRegistry,
|
mock_api: MethodRegistry,
|
||||||
mock_log_manager: Mock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
shared_threads: dict[str, tasks.WebThread] = {}
|
shared_threads: dict[str, tasks.WebThread] = {}
|
||||||
tasks.BAKEND_THREADS = shared_threads
|
tasks.BAKEND_THREADS = shared_threads
|
||||||
@@ -321,7 +303,6 @@ class TestIntegration:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ from clan_lib.api import MethodRegistry, message_queue
|
|||||||
from clan_lib.api.tasks import WebThread
|
from clan_lib.api.tasks import WebThread
|
||||||
|
|
||||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||||
|
from .webview_bridge import WebviewBridge
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from clan_app.api.middleware import Middleware
|
from clan_app.api.middleware import Middleware
|
||||||
|
|
||||||
from .webview_bridge import WebviewBridge
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ class Webview:
|
|||||||
shared_threads: dict[str, WebThread] | None = None
|
shared_threads: dict[str, WebThread] | None = None
|
||||||
|
|
||||||
# initialized later
|
# initialized later
|
||||||
_bridge: "WebviewBridge | None" = None
|
_bridge: WebviewBridge | None = None
|
||||||
_handle: Any | None = None
|
_handle: Any | None = None
|
||||||
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||||
_middleware: list["Middleware"] = field(default_factory=list)
|
_middleware: list["Middleware"] = field(default_factory=list)
|
||||||
@@ -81,7 +80,7 @@ class Webview:
|
|||||||
msg = message_queue.get() # Blocks until available
|
msg = message_queue.get() # Blocks until available
|
||||||
js_code = f"window.notifyBus({json.dumps(msg)});"
|
js_code = f"window.notifyBus({json.dumps(msg)});"
|
||||||
self.eval(js_code)
|
self.eval(js_code)
|
||||||
except Exception as e:
|
except (json.JSONDecodeError, RuntimeError, AttributeError) as e:
|
||||||
print("Bridge notify error:", e)
|
print("Bridge notify error:", e)
|
||||||
sleep(0.01) # avoid busy loop
|
sleep(0.01) # avoid busy loop
|
||||||
|
|
||||||
@@ -132,10 +131,8 @@ class Webview:
|
|||||||
|
|
||||||
self._middleware.append(middleware)
|
self._middleware.append(middleware)
|
||||||
|
|
||||||
def create_bridge(self) -> "WebviewBridge":
|
def create_bridge(self) -> WebviewBridge:
|
||||||
"""Create and initialize the WebviewBridge with current middleware."""
|
"""Create and initialize the WebviewBridge with current middleware."""
|
||||||
from .webview_bridge import WebviewBridge
|
|
||||||
|
|
||||||
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
||||||
if self.shared_threads is not None:
|
if self.shared_threads is not None:
|
||||||
bridge = WebviewBridge(
|
bridge = WebviewBridge(
|
||||||
@@ -211,7 +208,7 @@ class Webview:
|
|||||||
try:
|
try:
|
||||||
result = callback(*args)
|
result = callback(*args)
|
||||||
success = True
|
success = True
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
result = str(e)
|
result = str(e)
|
||||||
success = False
|
success = False
|
||||||
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
|
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 clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||||
|
|
||||||
from .webview import FuncStatus
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .webview import Webview
|
from .webview import Webview
|
||||||
|
|
||||||
@@ -32,6 +30,9 @@ class WebviewBridge(ApiBridge):
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.debug(f"Sending response: {serialized}")
|
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
|
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
|
||||||
|
|
||||||
def handle_webview_call(
|
def handle_webview_call(
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ let
|
|||||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
||||||
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
|
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
|
||||||
};
|
};
|
||||||
|
commitMono_ttf = fetchurl {
|
||||||
|
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
|
||||||
|
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
|
||||||
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
runCommand "" { } ''
|
runCommand "" { } ''
|
||||||
@@ -62,4 +66,5 @@ runCommand "" { } ''
|
|||||||
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
||||||
|
|
||||||
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
||||||
|
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
|
||||||
''
|
''
|
||||||
|
|||||||
55
pkgs/clan-app/ui/package-lock.json
generated
55
pkgs/clan-app/ui/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"solid-js": "^1.9.7",
|
"solid-js": "^1.9.7",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"three": "^0.176.0",
|
"three": "^0.176.0",
|
||||||
|
"troika-three-text": "^0.52.4",
|
||||||
"valibot": "^1.1.0"
|
"valibot": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3807,6 +3808,15 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -7528,6 +7538,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/requires-port": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
@@ -8655,6 +8674,36 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/troika-three-text": {
|
||||||
|
"version": "0.52.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||||
|
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"troika-three-utils": "^0.52.4",
|
||||||
|
"troika-worker-utils": "^0.52.0",
|
||||||
|
"webgl-sdf-generator": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.125.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/troika-three-utils": {
|
||||||
|
"version": "0.52.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
|
||||||
|
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.125.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/troika-worker-utils": {
|
||||||
|
"version": "0.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||||
|
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
@@ -9268,6 +9317,12 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webgl-sdf-generator": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"solid-js": "^1.9.7",
|
"solid-js": "^1.9.7",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"three": "^0.176.0",
|
"three": "^0.176.0",
|
||||||
|
"troika-three-text": "^0.52.4",
|
||||||
"valibot": "^1.1.0"
|
"valibot": "^1.1.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -5,10 +5,6 @@
|
|||||||
@apply pl-3;
|
@apply pl-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hasIcon svg.icon {
|
|
||||||
@apply relative top-0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.hasDismiss {
|
&.hasDismiss {
|
||||||
@apply pr-3;
|
@apply pr-3;
|
||||||
}
|
}
|
||||||
@@ -35,6 +31,10 @@
|
|||||||
&.noPadding {
|
&.noPadding {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
@apply relative top-0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alertContent {
|
.alertContent {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const Button = (props: ButtonProps) => {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
|
disabled={props.disabled || props.loading}
|
||||||
{...other}
|
{...other}
|
||||||
>
|
>
|
||||||
<Loader
|
<Loader
|
||||||
@@ -90,7 +91,6 @@ export const Button = (props: ButtonProps) => {
|
|||||||
<Typography
|
<Typography
|
||||||
class="label"
|
class="label"
|
||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
family="mono"
|
|
||||||
size={local.size || "default"}
|
size={local.size || "default"}
|
||||||
inverted={local.hierarchy === "primary"}
|
inverted={local.hierarchy === "primary"}
|
||||||
weight="bold"
|
weight="bold"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
width: 113px;
|
||||||
|
padding: 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--clr-border-def-2, #d8e8eb);
|
||||||
|
background: var(--clr-bg-def-1, #fff);
|
||||||
|
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
max-height: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-3;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
pkgs/clan-app/ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
61
pkgs/clan-app/ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { onCleanup, onMount } from "solid-js";
|
||||||
|
import styles from "./ContextMenu.module.css";
|
||||||
|
import { Typography } from "../Typography/Typography";
|
||||||
|
|
||||||
|
export const Menu = (props: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onSelect: (option: "move") => void;
|
||||||
|
close: () => void;
|
||||||
|
intersect: string[];
|
||||||
|
}) => {
|
||||||
|
let ref: HTMLUListElement;
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (!ref.contains(e.target as Node)) {
|
||||||
|
props.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() =>
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside),
|
||||||
|
);
|
||||||
|
const currentMachine = () => props.intersect.at(0) || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={(el) => (ref = el)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${props.y}px`,
|
||||||
|
left: `${props.x}px`,
|
||||||
|
"z-index": 1000,
|
||||||
|
"pointer-events": "auto",
|
||||||
|
}}
|
||||||
|
class={styles.list}
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class={styles.item}
|
||||||
|
aria-disabled={!currentMachine()}
|
||||||
|
onClick={() => {
|
||||||
|
console.log("Move clicked", currentMachine());
|
||||||
|
props.onSelect("move");
|
||||||
|
props.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="s"
|
||||||
|
weight="bold"
|
||||||
|
color={currentMachine() ? "primary" : "quaternary"}
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</Typography>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -164,17 +164,26 @@ export const MachineTags = (props: MachineTagsProps) => {
|
|||||||
<For each={state.selectedOptions()}>
|
<For each={state.selectedOptions()}>
|
||||||
{(option) => (
|
{(option) => (
|
||||||
<Tag
|
<Tag
|
||||||
label={option.value}
|
|
||||||
inverted={props.inverted}
|
inverted={props.inverted}
|
||||||
action={
|
interactive={
|
||||||
option.disabled || props.disabled || props.readOnly
|
!(option.disabled || props.disabled || props.readOnly)
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
icon: "Close",
|
|
||||||
onClick: () => state.remove(option),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/>
|
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>
|
</For>
|
||||||
<Show when={!props.readOnly}>
|
<Show when={!props.readOnly}>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
.modal_body {
|
.modal_body {
|
||||||
overflow-y: auto;
|
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] {
|
&[data-no-padding] {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
|
|||||||
@@ -2,20 +2,33 @@ import Icon from "../Icon/Icon";
|
|||||||
import { Button } from "../Button/Button";
|
import { Button } from "../Button/Button";
|
||||||
import styles from "./Search.module.css";
|
import styles from "./Search.module.css";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import { createMemo, createSignal, For, JSX } from "solid-js";
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
JSX,
|
||||||
|
Match,
|
||||||
|
Switch,
|
||||||
|
} from "solid-js";
|
||||||
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
|
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
|
||||||
import { CollectionNode } from "@kobalte/core/*";
|
import { CollectionNode } from "@kobalte/core/*";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Loader } from "../Loader/Loader";
|
||||||
|
|
||||||
export interface Option {
|
export interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemRenderOptions {
|
export interface ItemRenderOptions {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchMultipleProps<T> {
|
export interface SearchMultipleProps<T> {
|
||||||
|
values: T[]; // controlled values
|
||||||
onChange: (values: T[]) => void;
|
onChange: (values: T[]) => void;
|
||||||
options: T[];
|
options: T[];
|
||||||
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
|
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
|
||||||
@@ -23,12 +36,17 @@ export interface SearchMultipleProps<T> {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
|
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
|
||||||
height: string; // e.g. '14.5rem'
|
height: string; // e.g. '14.5rem'
|
||||||
|
headerClass?: string;
|
||||||
|
headerChildren?: JSX.Element;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingComponent?: JSX.Element;
|
||||||
|
divider?: boolean;
|
||||||
}
|
}
|
||||||
export function SearchMultiple<T extends Option>(
|
export function SearchMultiple<T extends Option>(
|
||||||
props: SearchMultipleProps<T>,
|
props: SearchMultipleProps<T>,
|
||||||
) {
|
) {
|
||||||
// Controlled input value, to allow resetting the input itself
|
// Controlled input value, to allow resetting the input itself
|
||||||
const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
// const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
||||||
const [inputValue, setInputValue] = createSignal<string>("");
|
const [inputValue, setInputValue] = createSignal<string>("");
|
||||||
|
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
@@ -54,30 +72,32 @@ export function SearchMultiple<T extends Option>(
|
|||||||
return item?.rawValue?.value || `item-${index}`;
|
return item?.rawValue?.value || `item-${index}`;
|
||||||
},
|
},
|
||||||
estimateSize: () => 42,
|
estimateSize: () => 42,
|
||||||
gap: 6,
|
gap: 0,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
...props.virtualizerOptions,
|
...props.virtualizerOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
return newVirtualizer;
|
return newVirtualizer;
|
||||||
});
|
});
|
||||||
|
createEffect(() => {
|
||||||
|
console.log("multi values:", props.values);
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<Combobox<T>
|
<Combobox<T>
|
||||||
multiple
|
multiple
|
||||||
value={values()}
|
value={props.values}
|
||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
setValues(() => values);
|
// setValues(() => values);
|
||||||
// setInputValue(value ? value.label : "");
|
console.log("onChange", values);
|
||||||
props.onChange(values);
|
props.onChange(values);
|
||||||
}}
|
}}
|
||||||
class={styles.searchContainer}
|
class={styles.searchContainer}
|
||||||
style={{ "--container-height": props.height }}
|
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
options={props.options}
|
options={props.options}
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
optionTextValue="label"
|
optionTextValue="label"
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
|
optionDisabled={"disabled"}
|
||||||
sameWidth={true}
|
sameWidth={true}
|
||||||
open={true}
|
open={true}
|
||||||
gutter={7}
|
gutter={7}
|
||||||
@@ -89,69 +109,78 @@ export function SearchMultiple<T extends Option>(
|
|||||||
triggerMode="manual"
|
triggerMode="manual"
|
||||||
noResetInputOnBlur={true}
|
noResetInputOnBlur={true}
|
||||||
>
|
>
|
||||||
<Combobox.Control<T> class={styles.searchHeader}>
|
<Combobox.Control<T>
|
||||||
|
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
|
||||||
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<div class={styles.inputContainer}>
|
<>
|
||||||
<Icon icon="Search" color="quaternary" />
|
{props.headerChildren}
|
||||||
<Combobox.Input
|
<div class={styles.inputContainer}>
|
||||||
ref={(el) => {
|
<Icon icon="Search" color="quaternary" />
|
||||||
inputEl = el;
|
<Combobox.Input
|
||||||
}}
|
ref={(el) => {
|
||||||
class={styles.searchInput}
|
inputEl = el;
|
||||||
placeholder={props.placeholder}
|
}}
|
||||||
value={inputValue()}
|
class={styles.searchInput}
|
||||||
onChange={(e) => {
|
placeholder={props.placeholder}
|
||||||
setInputValue(e.currentTarget.value);
|
value={inputValue()}
|
||||||
}}
|
onChange={(e) => {
|
||||||
/>
|
setInputValue(e.currentTarget.value);
|
||||||
<Button
|
}}
|
||||||
type="reset"
|
/>
|
||||||
hierarchy="primary"
|
<Button
|
||||||
size="s"
|
type="reset"
|
||||||
ghost
|
hierarchy="primary"
|
||||||
icon="CloseCircle"
|
size="s"
|
||||||
onClick={() => {
|
ghost
|
||||||
state.clear();
|
icon="CloseCircle"
|
||||||
setInputValue("");
|
onClick={() => {
|
||||||
|
state.clear();
|
||||||
|
setInputValue("");
|
||||||
|
|
||||||
// Dispatch an input event to notify combobox listeners
|
// Dispatch an input event to notify combobox listeners
|
||||||
inputEl.dispatchEvent(
|
inputEl.dispatchEvent(
|
||||||
new Event("input", { bubbles: true, cancelable: true }),
|
new Event("input", { bubbles: true, cancelable: true }),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Combobox.Control>
|
</Combobox.Control>
|
||||||
<Combobox.Portal>
|
<Combobox.Listbox<T>
|
||||||
<Combobox.Content
|
ref={(el) => {
|
||||||
class={styles.searchContent}
|
listboxRef = el;
|
||||||
tabIndex={-1}
|
}}
|
||||||
style={{ "--container-height": props.height }}
|
style={{
|
||||||
>
|
height: props.height,
|
||||||
<Combobox.Listbox<T>
|
width: "100%",
|
||||||
ref={(el) => {
|
overflow: "auto",
|
||||||
listboxRef = el;
|
"overflow-y": "auto",
|
||||||
}}
|
}}
|
||||||
style={{
|
scrollToItem={(key) => {
|
||||||
height: "100%",
|
const idx = comboboxItems().findIndex(
|
||||||
width: "100%",
|
(option) => option.rawValue.value === key,
|
||||||
overflow: "auto",
|
);
|
||||||
"overflow-y": "auto",
|
virtualizer().scrollToIndex(idx);
|
||||||
}}
|
}}
|
||||||
scrollToItem={(key) => {
|
class={styles.listbox}
|
||||||
const idx = comboboxItems().findIndex(
|
>
|
||||||
(option) => option.rawValue.value === key,
|
{(items) => {
|
||||||
);
|
// Update the virtualizer with the filtered items
|
||||||
virtualizer().scrollToIndex(idx);
|
const arr = Array.from(items());
|
||||||
}}
|
setComboboxItems(arr);
|
||||||
>
|
|
||||||
{(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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${virtualizer().getTotalSize()}px`,
|
height: `${virtualizer().getTotalSize()}px`,
|
||||||
@@ -169,11 +198,16 @@ export function SearchMultiple<T extends Option>(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const isSelected = () =>
|
const isSelected = () =>
|
||||||
values().some((v) => v.value === item.rawValue.value);
|
props.values.some(
|
||||||
|
(v) => v.value === item.rawValue.value,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Combobox.Item
|
<Combobox.Item
|
||||||
item={item}
|
item={item}
|
||||||
class={styles.searchItem}
|
class={cx(
|
||||||
|
styles.searchItem,
|
||||||
|
props.divider && styles.hasDivider,
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -185,17 +219,19 @@ export function SearchMultiple<T extends Option>(
|
|||||||
>
|
>
|
||||||
{props.renderItem(item.rawValue, {
|
{props.renderItem(item.rawValue, {
|
||||||
selected: isSelected(),
|
selected: isSelected(),
|
||||||
|
disabled: item.disabled,
|
||||||
})}
|
})}
|
||||||
</Combobox.Item>
|
</Combobox.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
</Match>
|
||||||
}}
|
</Switch>
|
||||||
</Combobox.Listbox>
|
);
|
||||||
</Combobox.Content>
|
}}
|
||||||
</Combobox.Portal>
|
</Combobox.Listbox>
|
||||||
|
{/* </Combobox.Content> */}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.searchHeader {
|
.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;
|
@apply px-3 pt-3 pb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +42,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.searchItem {
|
.searchItem {
|
||||||
&[data-highlighted],
|
@apply flex flex-col justify-center overflow-hidden;
|
||||||
&:focus,
|
|
||||||
&:focus-visible,
|
&.hasDivider {
|
||||||
&:hover {
|
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
|
||||||
@apply bg-inv-acc-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
/* Next element is hovered */
|
||||||
@apply bg-inv-acc-3;
|
&:has(+ &:hover) {
|
||||||
|
box-shadow: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@apply flex flex-col justify-center;
|
&:not([aria-disabled="true"])[data-highlighted],
|
||||||
|
&:not([aria-disabled="true"]):focus,
|
||||||
|
&:not([aria-disabled="true"]):focus-visible,
|
||||||
|
&:not([aria-disabled="true"]):hover {
|
||||||
|
@apply bg-inv-acc-2 rounded-md;
|
||||||
|
box-shadow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not([aria-disabled="true"]):active {
|
||||||
|
@apply bg-inv-acc-3 rounded-md;
|
||||||
|
box-shadow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
@apply cursor-not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchContainer {
|
.searchContainer {
|
||||||
@@ -61,16 +76,14 @@
|
|||||||
|
|
||||||
@apply rounded-lg;
|
@apply rounded-lg;
|
||||||
|
|
||||||
height: var(--container-height, 14.5rem);
|
|
||||||
|
|
||||||
border: 1px solid #2b4647;
|
border: 1px solid #2b4647;
|
||||||
|
|
||||||
background:
|
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(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%,
|
theme(colors.bg.inv.2) 0%,
|
||||||
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
|
theme(colors.bg.inv.3) 100%
|
||||||
);
|
);
|
||||||
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -78,10 +91,8 @@
|
|||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchContent {
|
.listbox {
|
||||||
@apply px-3;
|
@apply px-3 pt-3.5;
|
||||||
height: var(--container-height, 14.5rem);
|
|
||||||
padding-bottom: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes contentHide {
|
@keyframes contentHide {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
SearchMultiple,
|
SearchMultiple,
|
||||||
SearchMultipleProps,
|
SearchMultipleProps,
|
||||||
} from "./MultipleSearch";
|
} from "./MultipleSearch";
|
||||||
import { JSX, Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: "Components/Search",
|
title: "Components/Search",
|
||||||
@@ -55,8 +55,8 @@ function generateModules(count: number): Module[] {
|
|||||||
modules.push({
|
modules.push({
|
||||||
value: `lolcat/module-${i + 1}`,
|
value: `lolcat/module-${i + 1}`,
|
||||||
label: `Module ${i + 1}`,
|
label: `Module ${i + 1}`,
|
||||||
description: `${greek[i % greek.length]}#${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",
|
input: "lolcat-flake-part-from-nixpkgs-via-nix-via-clan-flake",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +72,13 @@ export interface Module {
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
height: "14.5rem",
|
||||||
// Test with lots of modules
|
// Test with lots of modules
|
||||||
options: generateModules(1000),
|
options: generateModules(1000),
|
||||||
renderItem: (item: Module) => {
|
renderItem: (item: Module) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
|
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
|
||||||
<div class="flex size-8 items-center justify-center rounded-md bg-white">
|
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||||
<Icon icon="Code" />
|
<Icon icon="Code" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-col">
|
<div class="flex w-full flex-col">
|
||||||
@@ -94,8 +95,12 @@ export const Default: Story = {
|
|||||||
inverted
|
inverted
|
||||||
class="flex justify-between"
|
class="flex justify-between"
|
||||||
>
|
>
|
||||||
<span>{item.description}</span>
|
<span class="inline-block max-w-72 truncate align-middle">
|
||||||
<span>by {item.input}</span>
|
{item.description}
|
||||||
|
</span>
|
||||||
|
<span class="inline-block max-w-20 truncate align-middle">
|
||||||
|
by {item.input}
|
||||||
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +109,7 @@ export const Default: Story = {
|
|||||||
},
|
},
|
||||||
render: (args: SearchProps<Module>) => {
|
render: (args: SearchProps<Module>) => {
|
||||||
return (
|
return (
|
||||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
<div class="fixed bottom-10 left-1/2 mb-2 w-[30rem] -translate-x-1/2">
|
||||||
<Search<Module>
|
<Search<Module>
|
||||||
{...args}
|
{...args}
|
||||||
onChange={(module) => {
|
onChange={(module) => {
|
||||||
@@ -117,32 +122,43 @@ export const Default: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
height: "14.5rem",
|
||||||
|
// Test with lots of modules
|
||||||
|
loading: true,
|
||||||
|
options: [],
|
||||||
|
renderItem: () => <span></span>,
|
||||||
|
},
|
||||||
|
render: (args: SearchProps<Module>) => {
|
||||||
|
return (
|
||||||
|
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||||
|
<Search<Module>
|
||||||
|
{...args}
|
||||||
|
onChange={(module) => {
|
||||||
|
// Go to the module configuration
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type MachineOrTag =
|
type MachineOrTag =
|
||||||
| {
|
| {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "machine";
|
type: "machine";
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
members: string[];
|
members: string[];
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
type: "tag";
|
type: "tag";
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WrapIfProps {
|
|
||||||
condition: boolean;
|
|
||||||
wrapper: (children: JSX.Element) => JSX.Element;
|
|
||||||
children: JSX.Element;
|
|
||||||
}
|
|
||||||
const WrapIf = (props: WrapIfProps) => {
|
|
||||||
if (props.condition) {
|
|
||||||
return props.wrapper(props.children);
|
|
||||||
} else {
|
|
||||||
return props.children;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const machinesAndTags: MachineOrTag[] = [
|
const machinesAndTags: MachineOrTag[] = [
|
||||||
{ value: "machine-1", label: "Machine 1", type: "machine" },
|
{ value: "machine-1", label: "Machine 1", type: "machine" },
|
||||||
{ value: "machine-2", label: "Machine 2", type: "machine" },
|
{ value: "machine-2", label: "Machine 2", type: "machine" },
|
||||||
@@ -183,7 +199,13 @@ export const Multiple: Story = {
|
|||||||
</Show>
|
</Show>
|
||||||
</Combobox.ItemIndicator>
|
</Combobox.ItemIndicator>
|
||||||
<Combobox.ItemLabel class="flex items-center gap-2">
|
<Combobox.ItemLabel class="flex items-center gap-2">
|
||||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="s"
|
||||||
|
weight="medium"
|
||||||
|
inverted
|
||||||
|
color={opts.disabled ? "quaternary" : "primary"}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Show when={item.type === "tag" && item}>
|
<Show when={item.type === "tag" && item}>
|
||||||
@@ -216,6 +238,7 @@ export const Multiple: Story = {
|
|||||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||||
<SearchMultiple<MachineOrTag>
|
<SearchMultiple<MachineOrTag>
|
||||||
{...args}
|
{...args}
|
||||||
|
divider
|
||||||
height="20rem"
|
height="20rem"
|
||||||
virtualizerOptions={{
|
virtualizerOptions={{
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import Icon from "../Icon/Icon";
|
|||||||
import { Button } from "../Button/Button";
|
import { Button } from "../Button/Button";
|
||||||
import styles from "./Search.module.css";
|
import styles from "./Search.module.css";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import { createMemo, createSignal, For, JSX } from "solid-js";
|
import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
|
||||||
import { createVirtualizer } from "@tanstack/solid-virtual";
|
import { createVirtualizer } from "@tanstack/solid-virtual";
|
||||||
import { CollectionNode } from "@kobalte/core/*";
|
import { CollectionNode } from "@kobalte/core/*";
|
||||||
|
import { Loader } from "../Loader/Loader";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
export interface Option {
|
export interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchProps<T> {
|
export interface SearchProps<T> {
|
||||||
onChange: (value: T | null) => void;
|
onChange: (value: T | null) => void;
|
||||||
options: T[];
|
options: T[];
|
||||||
renderItem: (item: T) => JSX.Element;
|
renderItem: (item: T, opts: { disabled: boolean }) => JSX.Element;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingComponent?: JSX.Element;
|
||||||
|
headerClass?: string;
|
||||||
|
height: string; // e.g. '14.5rem'
|
||||||
|
divider?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Search<T extends Option>(props: SearchProps<T>) {
|
export function Search<T extends Option>(props: SearchProps<T>) {
|
||||||
// Controlled input value, to allow resetting the input itself
|
// Controlled input value, to allow resetting the input itself
|
||||||
const [value, setValue] = createSignal<T | null>(null);
|
const [value, setValue] = createSignal<T | null>(null);
|
||||||
@@ -59,13 +68,14 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
|||||||
setInputValue(value ? value.label : "");
|
setInputValue(value ? value.label : "");
|
||||||
props.onChange(value);
|
props.onChange(value);
|
||||||
}}
|
}}
|
||||||
class={styles.searchContainer}
|
class={cx(styles.searchContainer, props.divider && styles.hasDivider)}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
options={props.options}
|
options={props.options}
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
optionTextValue="label"
|
optionTextValue="label"
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
placeholder="Search a service"
|
placeholder="Search a service"
|
||||||
|
optionDisabled={"disabled"}
|
||||||
sameWidth={true}
|
sameWidth={true}
|
||||||
open={true}
|
open={true}
|
||||||
gutter={7}
|
gutter={7}
|
||||||
@@ -77,7 +87,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
|||||||
triggerMode="manual"
|
triggerMode="manual"
|
||||||
noResetInputOnBlur={true}
|
noResetInputOnBlur={true}
|
||||||
>
|
>
|
||||||
<Combobox.Control<T> class={styles.searchHeader}>
|
<Combobox.Control<T>
|
||||||
|
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
|
||||||
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<div class={styles.inputContainer}>
|
<div class={styles.inputContainer}>
|
||||||
<Icon icon="Search" color="quaternary" />
|
<Icon icon="Search" color="quaternary" />
|
||||||
@@ -111,31 +123,39 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Combobox.Control>
|
</Combobox.Control>
|
||||||
<Combobox.Portal>
|
<Combobox.Listbox<T>
|
||||||
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
|
ref={(el) => {
|
||||||
<Combobox.Listbox<T>
|
listboxRef = el;
|
||||||
ref={(el) => {
|
}}
|
||||||
listboxRef = el;
|
style={{
|
||||||
}}
|
height: props.height,
|
||||||
style={{
|
width: "100%",
|
||||||
height: "100%",
|
overflow: "auto",
|
||||||
width: "100%",
|
"overflow-y": "auto",
|
||||||
overflow: "auto",
|
}}
|
||||||
"overflow-y": "auto",
|
class={styles.listbox}
|
||||||
}}
|
scrollToItem={(key) => {
|
||||||
scrollToItem={(key) => {
|
const idx = comboboxItems().findIndex(
|
||||||
const idx = comboboxItems().findIndex(
|
(option) => option.rawValue.value === key,
|
||||||
(option) => option.rawValue.value === key,
|
);
|
||||||
);
|
virtualizer().scrollToIndex(idx);
|
||||||
virtualizer().scrollToIndex(idx);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{(items) => {
|
||||||
{(items) => {
|
// Update the virtualizer with the filtered items
|
||||||
// Update the virtualizer with the filtered items
|
const arr = Array.from(items());
|
||||||
const arr = Array.from(items());
|
setComboboxItems(arr);
|
||||||
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${virtualizer().getTotalSize()}px`,
|
height: `${virtualizer().getTotalSize()}px`,
|
||||||
@@ -165,17 +185,19 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
|||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.renderItem(item.rawValue)}
|
{props.renderItem(item.rawValue, {
|
||||||
|
disabled: item.disabled,
|
||||||
|
})}
|
||||||
</Combobox.Item>
|
</Combobox.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
</Match>
|
||||||
}}
|
</Switch>
|
||||||
</Combobox.Listbox>
|
);
|
||||||
</Combobox.Content>
|
}}
|
||||||
</Combobox.Portal>
|
</Combobox.Listbox>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
pkgs/clan-app/ui/src/components/Search/TagSelect.module.css
Normal file
20
pkgs/clan-app/ui/src/components/Search/TagSelect.module.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.trigger {
|
||||||
|
@apply rounded-md bg-inv-4 w-full min-h-11;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
@apply outline outline-def-1 outline-1;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--clr-bg-inv-acc-3, #2c4347) 0%,
|
||||||
|
var(--clr-bg-inv-acc-2, #4f747a) 100%
|
||||||
|
),
|
||||||
|
var(--clr-bg-inv-4, #203637);
|
||||||
|
box-shadow: 0 0 0 2px var(--clr-bg-inv-acc-4, #162324) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
@apply bg-inv-acc-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
pkgs/clan-app/ui/src/components/Search/TagSelect.stories.tsx
Normal file
62
pkgs/clan-app/ui/src/components/Search/TagSelect.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
|
||||||
|
import { TagSelect, TagSelectProps } from "./TagSelect";
|
||||||
|
import { Tag } from "../Tag/Tag";
|
||||||
|
import Icon from "../Icon/Icon";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Custom/SelectStepper",
|
||||||
|
component: TagSelect,
|
||||||
|
} satisfies Meta<TagSelectProps<string>>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Story = StoryObj<TagSelectProps<Item>>;
|
||||||
|
|
||||||
|
const Item = (item: Item) => (
|
||||||
|
<Tag
|
||||||
|
inverted
|
||||||
|
icon={(tag) => (
|
||||||
|
<Icon icon={"Machine"} size="0.5rem" inverted={tag.inverted} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
renderItem: Item,
|
||||||
|
label: "Peer",
|
||||||
|
options: [
|
||||||
|
{ value: "foo", label: "Foo" },
|
||||||
|
{ value: "bar", label: "Bar" },
|
||||||
|
{ value: "baz", label: "Baz" },
|
||||||
|
{ value: "qux", label: "Qux" },
|
||||||
|
{ value: "quux", label: "Quux" },
|
||||||
|
{ value: "corge", label: "Corge" },
|
||||||
|
{ value: "grault", label: "Grault" },
|
||||||
|
],
|
||||||
|
} satisfies Partial<TagSelectProps<Item>>,
|
||||||
|
render: (args: TagSelectProps<Item>) => {
|
||||||
|
const [state, setState] = createSignal<Item[]>([]);
|
||||||
|
return (
|
||||||
|
<TagSelect<Item>
|
||||||
|
{...args}
|
||||||
|
values={state()}
|
||||||
|
onClick={() => {
|
||||||
|
console.log("Clicked, current values:");
|
||||||
|
setState(() => [
|
||||||
|
{ value: "baz", label: "Baz" },
|
||||||
|
{ value: "qux", label: "Qux" },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
85
pkgs/clan-app/ui/src/components/Search/TagSelect.tsx
Normal file
85
pkgs/clan-app/ui/src/components/Search/TagSelect.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Icon from "../Icon/Icon";
|
||||||
|
import { Typography } from "../Typography/Typography";
|
||||||
|
import { For, JSX, Show } from "solid-js";
|
||||||
|
import styles from "./TagSelect.module.css";
|
||||||
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
|
import { Button } from "../Button/Button";
|
||||||
|
|
||||||
|
// Base props common to both modes
|
||||||
|
export interface TagSelectProps<T> {
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
values: T[];
|
||||||
|
options: T[];
|
||||||
|
renderItem: (item: T) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallowly interactive field for selecting multiple tags / machines.
|
||||||
|
* It does only handle click and focus interactions
|
||||||
|
* Displays the selected items as tags
|
||||||
|
*/
|
||||||
|
export function TagSelect<T extends { value: unknown }>(
|
||||||
|
props: TagSelectProps<T>,
|
||||||
|
) {
|
||||||
|
const optionValue = "value";
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="flex w-full items-center gap-2 px-1.5 py-0">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
weight="medium"
|
||||||
|
class="flex gap-2 uppercase"
|
||||||
|
size="xs"
|
||||||
|
inverted
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</Typography>
|
||||||
|
<Icon icon="Info" color="tertiary" inverted size={11} />
|
||||||
|
<Button
|
||||||
|
icon="Settings"
|
||||||
|
hierarchy="primary"
|
||||||
|
ghost
|
||||||
|
class="ml-auto"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Combobox<T>
|
||||||
|
multiple
|
||||||
|
optionValue={optionValue}
|
||||||
|
value={props.values}
|
||||||
|
options={props.options}
|
||||||
|
allowsEmptyCollection
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<Combobox.Control<T> aria-label="Fruits">
|
||||||
|
{(state) => {
|
||||||
|
return (
|
||||||
|
<Combobox.Trigger
|
||||||
|
tabIndex={1}
|
||||||
|
class={styles.trigger}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 px-2 py-3">
|
||||||
|
<Icon icon="Search" color="quaternary" inverted />
|
||||||
|
<Show when={state.selectedOptions().length === 0}>
|
||||||
|
<Typography
|
||||||
|
color="tertiary"
|
||||||
|
inverted
|
||||||
|
hierarchy="body"
|
||||||
|
size="s"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Typography>
|
||||||
|
</Show>
|
||||||
|
<For each={state.selectedOptions()}>{props.renderItem}</For>
|
||||||
|
</div>
|
||||||
|
</Combobox.Trigger>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Combobox.Control>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
@apply w-60 border-none z-10;
|
@apply w-60 border-none z-10 h-full flex flex-col;
|
||||||
|
|
||||||
.body {
|
|
||||||
@apply pt-4 pb-3 px-2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import styles from "./Sidebar.module.css";
|
|||||||
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||||
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
import { splitProps } from "solid-js";
|
||||||
|
|
||||||
export interface LinkProps {
|
export interface LinkProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -19,10 +20,12 @@ export interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = (props: SidebarProps) => {
|
export const Sidebar = (props: SidebarProps) => {
|
||||||
|
const [bodyProps] = splitProps(props, ["staticSections"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={cx(styles.sidebar, props.class)}>
|
<div class={cx(styles.sidebar, props.class)}>
|
||||||
<SidebarHeader />
|
<SidebarHeader />
|
||||||
<SidebarBody class={cx(styles.body)} {...props} />
|
<SidebarBody {...bodyProps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { A } from "@solidjs/router";
|
|||||||
import { Accordion } from "@kobalte/core/accordion";
|
import { Accordion } from "@kobalte/core/accordion";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { For, useContext } from "solid-js";
|
import { For, Show, useContext } from "solid-js";
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import { SidebarProps } from "./Sidebar";
|
import { SidebarProps } from "./Sidebar";
|
||||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
import { Button } from "../Button/Button";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
|
||||||
interface MachineProps {
|
interface MachineProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -58,10 +59,7 @@ const MachineRoute = (props: MachineProps) => {
|
|||||||
export const SidebarBody = (props: SidebarProps) => {
|
export const SidebarBody = (props: SidebarProps) => {
|
||||||
const clanURI = useClanURI();
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useClanContext();
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("ClanContext not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionLabels = (props.staticSections || []).map(
|
const sectionLabels = (props.staticSections || []).map(
|
||||||
(section) => section.title,
|
(section) => section.title,
|
||||||
@@ -71,6 +69,15 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
// we want them all to be open by default
|
// we want them all to be open by default
|
||||||
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
||||||
|
|
||||||
|
const machines = () => {
|
||||||
|
if (!ctx.machinesQuery.isSuccess) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ctx.machinesQuery.data;
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body">
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -100,18 +107,42 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Content class="content">
|
<Accordion.Content class="content">
|
||||||
<nav>
|
<Show
|
||||||
<For each={Object.entries(ctx.machinesQuery.data || {})}>
|
when={machines()}
|
||||||
{([id, machine]) => (
|
fallback={
|
||||||
<MachineRoute
|
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
||||||
clanURI={clanURI}
|
<Typography
|
||||||
machineID={id}
|
hierarchy="body"
|
||||||
name={machine.name || id}
|
size="s"
|
||||||
serviceCount={0}
|
weight="medium"
|
||||||
/>
|
inverted
|
||||||
)}
|
>
|
||||||
</For>
|
No machines yet
|
||||||
</nav>
|
</Typography>
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
size="s"
|
||||||
|
startIcon="Machine"
|
||||||
|
onClick={() => ctx.setShowAddMachine(true)}
|
||||||
|
>
|
||||||
|
Add machine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav>
|
||||||
|
<For each={Object.entries(machines()!)}>
|
||||||
|
{([id, machine]) => (
|
||||||
|
<MachineRoute
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineID={id}
|
||||||
|
name={machine.name || id}
|
||||||
|
serviceCount={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</Show>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import Icon from "@/src/components/Icon/Icon";
|
|||||||
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||||
import { useNavigate } from "@solidjs/router";
|
import { useNavigate } from "@solidjs/router";
|
||||||
import { Typography } from "../Typography/Typography";
|
import { Typography } from "../Typography/Typography";
|
||||||
import { createSignal, For, Show, Suspense, useContext } from "solid-js";
|
import { createSignal, For, Show, Suspense } from "solid-js";
|
||||||
import { navigateToOnboarding } from "@/src/hooks/clan";
|
import { navigateToOnboarding } from "@/src/hooks/clan";
|
||||||
import { setActiveClanURI } from "@/src/stores/clan";
|
import { setActiveClanURI } from "@/src/stores/clan";
|
||||||
import { Button } from "../Button/Button";
|
import { Button } from "../Button/Button";
|
||||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
|
||||||
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
|
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
|
||||||
|
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
|
||||||
export const SidebarHeader = () => {
|
export const SidebarHeader = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,11 +17,7 @@ export const SidebarHeader = () => {
|
|||||||
const [showSettings, setShowSettings] = createSignal(false);
|
const [showSettings, setShowSettings] = createSignal(false);
|
||||||
|
|
||||||
// get information about the current active clan
|
// get information about the current active clan
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useClanContext();
|
||||||
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("SidebarContext not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const clanChar = () =>
|
const clanChar = () =>
|
||||||
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
|
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
div.sidebar-pane {
|
div.sidebar-pane {
|
||||||
@apply border-none z-10;
|
@apply flex flex-col border-none z-20 h-full;
|
||||||
|
|
||||||
animation: sidebarPaneShow 250ms ease-in forwards;
|
animation: sidebarPaneShow 250ms ease-in forwards;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
@apply w-60;
|
@apply w-72;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.closing {
|
&.closing {
|
||||||
@@ -90,7 +90,7 @@ div.sidebar-pane {
|
|||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
@apply w-60;
|
@apply w-72;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
|
|||||||
class?: string;
|
class?: string;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
subHeader?: () => JSX.Element;
|
subHeader?: JSX.Element;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
|||||||
</KButton>
|
</KButton>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.subHeader}>
|
<Show when={props.subHeader}>
|
||||||
<div class="sub-header">{props.subHeader!()}</div>
|
<div class="sub-header">{props.subHeader}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="body">{props.children}</div>
|
<div class="body">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
div.sidebar-section {
|
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 {
|
& > div.header {
|
||||||
@apply flex items-center justify-between px-1.5;
|
@apply flex items-center justify-between px-1.5;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const SidebarSection = (props: SidebarSectionProps) => {
|
|||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
size="xs"
|
size="xs"
|
||||||
family="mono"
|
family="mono"
|
||||||
weight="light"
|
|
||||||
transform="uppercase"
|
transform="uppercase"
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
inverted={true}
|
inverted={true}
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export function SidebarSectionForm<
|
|||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
size="xs"
|
size="xs"
|
||||||
family="mono"
|
family="mono"
|
||||||
weight="light"
|
|
||||||
transform="uppercase"
|
transform="uppercase"
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
inverted
|
inverted
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, Show } from "solid-js";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { InstallModal } from "@/src/workflows/Install/install";
|
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
|
||||||
import { useMachineName } from "@/src/hooks/clan";
|
import { useMachineName } from "@/src/hooks/clan";
|
||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import styles from "./SidebarSectionInstall.module.css";
|
import styles from "./SidebarSectionInstall.module.css";
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ span.tag {
|
|||||||
|
|
||||||
&.has-action {
|
&.has-action {
|
||||||
@apply pr-1.5;
|
@apply pr-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-interactive {
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-def-acc-3;
|
@apply bg-def-acc-3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Tag, TagProps } from "@/src/components/Tag/Tag";
|
import { Tag, TagProps } from "@/src/components/Tag/Tag";
|
||||||
import { Meta, type StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
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> = {
|
const meta: Meta<TagProps> = {
|
||||||
title: "Components/Tag",
|
title: "Components/Tag",
|
||||||
@@ -13,27 +14,44 @@ type Story = StoryObj<TagProps>;
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
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 = {
|
export const WithAction: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
action: {
|
icon: IconAction,
|
||||||
icon: "Close",
|
interactive: true,
|
||||||
onClick: fn(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
|
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
|
||||||
await userEvent.click(canvas.getByRole("button"));
|
await userEvent.click(canvas.getByRole("button"));
|
||||||
await expect(args.action.onClick).toHaveBeenCalled();
|
// await expect(args.icon.onClick).toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Inverted: Story = {
|
export const Inverted: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Label",
|
children: "Label",
|
||||||
inverted: true,
|
inverted: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import "./Tag.css";
|
|||||||
|
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, JSX } from "solid-js";
|
||||||
import Icon, { IconVariant } from "../Icon/Icon";
|
|
||||||
|
|
||||||
export interface TagAction {
|
interface IconActionProps {
|
||||||
icon: IconVariant;
|
inverted: boolean;
|
||||||
onClick: () => void;
|
handleActionClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagProps {
|
export interface TagProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
||||||
label: string;
|
children?: JSX.Element;
|
||||||
action?: TagAction;
|
icon?: (state: IconActionProps) => JSX.Element;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag = (props: TagProps) => {
|
export const Tag = (props: TagProps) => {
|
||||||
@@ -23,7 +24,6 @@ export const Tag = (props: TagProps) => {
|
|||||||
|
|
||||||
const handleActionClick = () => {
|
const handleActionClick = () => {
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
props.action?.onClick();
|
|
||||||
setTimeout(() => setIsActive(false), 150);
|
setTimeout(() => setIsActive(false), 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,23 +32,18 @@ export const Tag = (props: TagProps) => {
|
|||||||
class={cx("tag", {
|
class={cx("tag", {
|
||||||
inverted: inverted(),
|
inverted: inverted(),
|
||||||
active: isActive(),
|
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()}>
|
<Typography hierarchy="label" size="xs" inverted={inverted()}>
|
||||||
{props.label}
|
{props.children}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Show when={props.action}>
|
{props.icon?.({
|
||||||
<Icon
|
inverted: inverted(),
|
||||||
role="button"
|
handleActionClick,
|
||||||
icon={props.action!.icon}
|
})}
|
||||||
size="0.5rem"
|
|
||||||
inverted={inverted()}
|
|
||||||
onClick={handleActionClick}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const TagGroup = (props: TagGroupProps) => {
|
|||||||
return (
|
return (
|
||||||
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
|
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
|
||||||
<For each={props.labels}>
|
<For each={props.labels}>
|
||||||
{(label) => <Tag label={label} inverted={inverted()} />}
|
{(label) => <Tag inverted={inverted()}>{label}</Tag>}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/* Body */
|
/* Body */
|
||||||
.typography {
|
.typography {
|
||||||
&.weight-light {
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.weight-normal {
|
&.weight-normal {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
@@ -71,6 +67,12 @@
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
letter-spacing: 0.008125rem;
|
letter-spacing: 0.008125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.size-xxs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
/* letter-spacing: 0.008125rem; */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.family-mono {
|
&.family-mono {
|
||||||
@@ -93,6 +95,11 @@
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
letter-spacing: 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 Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
||||||
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
|
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 Family = "regular" | "condensed" | "mono";
|
||||||
export type Transform = "uppercase" | "lowercase" | "capitalize";
|
export type Transform = "uppercase" | "lowercase" | "capitalize";
|
||||||
|
|
||||||
@@ -87,7 +87,6 @@ const weightMap: Record<Weight, string> = {
|
|||||||
normal: "weight-normal",
|
normal: "weight-normal",
|
||||||
medium: "weight-medium",
|
medium: "weight-medium",
|
||||||
bold: "weight-bold",
|
bold: "weight-bold",
|
||||||
light: "weight-light",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface _TypographyProps<H extends Hierarchy> {
|
interface _TypographyProps<H extends Hierarchy> {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const DefaultQueryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type MachinesQuery = ReturnType<typeof useMachinesQuery>;
|
||||||
export const useMachinesQuery = (clanURI: string) => {
|
export const useMachinesQuery = (clanURI: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
|
|
||||||
@@ -117,11 +118,32 @@ 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) => {
|
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineState>(() => ({
|
return useQuery<MachineState>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||||
refetchInterval: 1000 * 60, // poll every 60 seconds
|
staleTime: 60_000, // 1 minute stale time
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const apiCall = client.fetch("get_machine_state", {
|
const apiCall = client.fetch("get_machine_state", {
|
||||||
machine: {
|
machine: {
|
||||||
@@ -456,3 +478,53 @@ export const useMachineGenerators = (
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServiceModulesQuery = ReturnType<typeof useServiceModules>;
|
||||||
|
export type ServiceModules = SuccessData<"list_service_modules">;
|
||||||
|
export const useServiceModules = (clanUri: string) => {
|
||||||
|
const client = useApiClient();
|
||||||
|
return useQuery(() => ({
|
||||||
|
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = client.fetch("list_service_modules", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanUri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
// todo should we create some specific error types?
|
||||||
|
console.error("Error fetching clan details:", result.errors);
|
||||||
|
throw new Error(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
||||||
|
export type ServiceInstances = SuccessData<"list_service_instances">;
|
||||||
|
export const useServiceInstances = (clanUri: string) => {
|
||||||
|
const client = useApiClient();
|
||||||
|
return useQuery(() => ({
|
||||||
|
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = client.fetch("list_service_instances", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanUri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
// todo should we create some specific error types?
|
||||||
|
console.error("Error fetching clan details:", result.errors);
|
||||||
|
throw new Error(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|||||||
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { onCleanup } from "solid-js";
|
||||||
|
|
||||||
|
export function useClickOutside(
|
||||||
|
el: () => HTMLElement | undefined,
|
||||||
|
handler: (e: MouseEvent) => void,
|
||||||
|
) {
|
||||||
|
const listener = (e: MouseEvent) => {
|
||||||
|
const element = el();
|
||||||
|
if (element && !element.contains(e.target as Node)) {
|
||||||
|
handler(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", listener);
|
||||||
|
onCleanup(() => document.removeEventListener("mousedown", listener));
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
|||||||
import { ApiClientProvider } from "./hooks/ApiClient";
|
import { ApiClientProvider } from "./hooks/ApiClient";
|
||||||
import { callApi } from "./hooks/api";
|
import { callApi } from "./hooks/api";
|
||||||
import { DefaultQueryClient } from "@/src/hooks/queries";
|
import { DefaultQueryClient } from "@/src/hooks/queries";
|
||||||
|
import { Toaster } from "solid-toast";
|
||||||
|
|
||||||
const root = document.getElementById("app");
|
const root = document.getElementById("app");
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ if (import.meta.env.DEV) {
|
|||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<ApiClientProvider client={{ fetch: callApi }}>
|
<ApiClientProvider client={{ fetch: callApi }}>
|
||||||
|
{/* Temporary solution */}
|
||||||
|
<Toaster toastOptions={{}} />
|
||||||
<QueryClientProvider client={DefaultQueryClient}>
|
<QueryClientProvider client={DefaultQueryClient}>
|
||||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||||
<Router root={Layout}>{Routes}</Router>
|
<Router root={Layout}>{Routes}</Router>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
|
|||||||
disabled={removeDisabled()}
|
disabled={removeDisabled()}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
Remove
|
Remove Clan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,38 @@
|
|||||||
@apply min-w-96;
|
@apply min-w-96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebarContainer {
|
||||||
@apply absolute left-4 top-10 w-60;
|
@apply absolute left-4 top-4 w-60 z-10;
|
||||||
@apply min-h-96;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import {
|
import {
|
||||||
|
buildClanPath,
|
||||||
buildMachinePath,
|
buildMachinePath,
|
||||||
maybeUseMachineName,
|
maybeUseMachineName,
|
||||||
useClanURI,
|
useClanURI,
|
||||||
|
useMachineName,
|
||||||
} from "@/src/hooks/clan";
|
} from "@/src/hooks/clan";
|
||||||
import { CubeScene } from "@/src/scene/cubes";
|
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||||
import {
|
import {
|
||||||
ClanDetails,
|
ClanDetails,
|
||||||
MachinesQueryResult,
|
MachinesQueryResult,
|
||||||
@@ -23,19 +25,21 @@ import {
|
|||||||
useClanListQuery,
|
useClanListQuery,
|
||||||
useMachinesQuery,
|
useMachinesQuery,
|
||||||
} from "@/src/hooks/queries";
|
} from "@/src/hooks/queries";
|
||||||
import { callApi } from "@/src/hooks/api";
|
|
||||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
|
||||||
import { Splash } from "@/src/scene/splash";
|
import { Splash } from "@/src/scene/splash";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import styles from "./Clan.module.css";
|
import styles from "./Clan.module.css";
|
||||||
import { Modal } from "@/src/components/Modal/Modal";
|
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
|
||||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
|
||||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
import { UseQueryResult } from "@tanstack/solid-query";
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||||
|
import {
|
||||||
|
ServiceWorkflow,
|
||||||
|
SubmitServiceHandler,
|
||||||
|
} from "@/src/workflows/Service/Service";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import toast from "solid-toast";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
|
||||||
interface ClanContextProps {
|
interface ClanContextProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -46,45 +50,43 @@ interface ClanContextProps {
|
|||||||
|
|
||||||
isLoading(): boolean;
|
isLoading(): boolean;
|
||||||
isError(): boolean;
|
isError(): boolean;
|
||||||
|
|
||||||
|
showAddMachine(): boolean;
|
||||||
|
setShowAddMachine(value: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultClanContext implements ClanContextProps {
|
function createClanContext(
|
||||||
public readonly clanURI: string;
|
clanURI: string,
|
||||||
|
machinesQuery: MachinesQueryResult,
|
||||||
|
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||||
|
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||||
|
) {
|
||||||
|
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||||
|
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||||
|
const allQueries = [machinesQuery, ...allClansQueries];
|
||||||
|
|
||||||
public readonly activeClanQuery: UseQueryResult<ClanDetails>;
|
return {
|
||||||
public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
|
clanURI,
|
||||||
public readonly allClansQueries: UseQueryResult<ClanDetails>[];
|
machinesQuery,
|
||||||
|
activeClanQuery,
|
||||||
public readonly machinesQuery: MachinesQueryResult;
|
otherClanQueries,
|
||||||
|
allClansQueries,
|
||||||
allQueries: UseQueryResult[];
|
isLoading: () => allQueries.some((q) => q.isLoading),
|
||||||
|
isError: () => activeClanQuery.isError,
|
||||||
constructor(
|
showAddMachine,
|
||||||
clanURI: string,
|
setShowAddMachine,
|
||||||
machinesQuery: MachinesQueryResult,
|
};
|
||||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
|
||||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
|
||||||
) {
|
|
||||||
this.clanURI = clanURI;
|
|
||||||
this.machinesQuery = machinesQuery;
|
|
||||||
|
|
||||||
this.activeClanQuery = activeClanQuery;
|
|
||||||
this.otherClanQueries = otherClanQueries;
|
|
||||||
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
|
|
||||||
|
|
||||||
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading(): boolean {
|
|
||||||
return this.allQueries.some((q) => q.isLoading);
|
|
||||||
}
|
|
||||||
|
|
||||||
isError(): boolean {
|
|
||||||
return this.activeClanQuery.isError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClanContext = createContext<ClanContextProps>();
|
const ClanContext = createContext<ClanContextProps>();
|
||||||
|
|
||||||
|
export const useClanContext = () => {
|
||||||
|
const ctx = useContext(ClanContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("ClanContext not found");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||||
const clanURI = useClanURI();
|
const clanURI = useClanURI();
|
||||||
@@ -103,122 +105,47 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
|||||||
|
|
||||||
const machinesQuery = useMachinesQuery(clanURI);
|
const machinesQuery = useMachinesQuery(clanURI);
|
||||||
|
|
||||||
|
const ctx = createClanContext(
|
||||||
|
clanURI,
|
||||||
|
machinesQuery,
|
||||||
|
activeClanQuery,
|
||||||
|
otherClanQueries,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClanContext.Provider
|
<ClanContext.Provider value={ctx}>
|
||||||
value={
|
<div
|
||||||
new DefaultClanContext(
|
class={cx(styles.sidebarContainer, {
|
||||||
clanURI,
|
[styles.machineSelected]: useMachineName(),
|
||||||
machinesQuery,
|
})}
|
||||||
activeClanQuery,
|
>
|
||||||
otherClanQueries,
|
<Sidebar />
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Sidebar class={cx(styles.sidebar)} />
|
|
||||||
{props.children}
|
{props.children}
|
||||||
<ClanSceneController {...props} />
|
<ClanSceneController {...props} />
|
||||||
</ClanContext.Provider>
|
</ClanContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CreateFormValues extends FieldValues {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (formValues: CreateFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MockCreateMachine = (props: MockProps) => {
|
|
||||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={true}
|
|
||||||
onClose={() => {
|
|
||||||
reset(form);
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
class={cx(styles.createModal)}
|
|
||||||
title="Create Machine"
|
|
||||||
>
|
|
||||||
<Form class="flex flex-col" onSubmit={props.onSubmit}>
|
|
||||||
<Field name="name">
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
{...field}
|
|
||||||
label="Name"
|
|
||||||
size="s"
|
|
||||||
required={true}
|
|
||||||
input={{ ...props, placeholder: "name", autofocus: true }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="mt-4 flex w-full items-center justify-end gap-4">
|
|
||||||
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClanSceneController = (props: RouteSectionProps) => {
|
const ClanSceneController = (props: RouteSectionProps) => {
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useClanContext();
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("ClanContext not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
const [showService, setShowService] = createSignal(false);
|
||||||
|
|
||||||
|
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||||
resolve: ({ id }: { id: string }) => void;
|
resolve: ({ id }: { id: string }) => void;
|
||||||
reject: (err: unknown) => void;
|
reject: (err: unknown) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const onCreate = async (): Promise<{ id: string }> => {
|
const onCreate = async (): Promise<{ id: string }> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setShowModal(true);
|
ctx.setShowAddMachine(true);
|
||||||
setDialogHandlers({ resolve, reject });
|
setCurrentPromise({ resolve, reject });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendCreate = async (values: CreateFormValues) => {
|
|
||||||
const api = callApi("create_machine", {
|
|
||||||
opts: {
|
|
||||||
clan_dir: {
|
|
||||||
identifier: ctx.clanURI,
|
|
||||||
},
|
|
||||||
machine: {
|
|
||||||
name: values.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const res = await api.result;
|
|
||||||
if (res.status === "error") {
|
|
||||||
// TODO: Handle displaying errors
|
|
||||||
console.error("Error creating machine:");
|
|
||||||
|
|
||||||
// Important: rejects the promise
|
|
||||||
throw new Error(res.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// trigger a refetch of the machines query
|
|
||||||
ctx.machinesQuery.refetch();
|
|
||||||
|
|
||||||
return { id: values.name };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = createSignal(false);
|
|
||||||
|
|
||||||
const [loadingError, setLoadingError] = createSignal<
|
const [loadingError, setLoadingError] = createSignal<
|
||||||
{ title: string; description: string } | undefined
|
{ title: string; description: string } | undefined
|
||||||
>();
|
>();
|
||||||
@@ -246,6 +173,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
const selected = ids.values().next().value;
|
const selected = ids.values().next().value;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
navigate(buildMachinePath(ctx.clanURI, selected));
|
navigate(buildMachinePath(ctx.clanURI, selected));
|
||||||
|
} else {
|
||||||
|
navigate(buildClanPath(ctx.clanURI));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,27 +194,64 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const client = useApiClient();
|
||||||
|
const handleSubmitService: SubmitServiceHandler = async (
|
||||||
|
instance,
|
||||||
|
action,
|
||||||
|
) => {
|
||||||
|
console.log(action, "Instance", instance);
|
||||||
|
|
||||||
|
if (action !== "create") {
|
||||||
|
toast.error("Only creating new services is supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const call = client.fetch("create_service_instance", {
|
||||||
|
flake: {
|
||||||
|
identifier: ctx.clanURI,
|
||||||
|
},
|
||||||
|
module_ref: instance.module,
|
||||||
|
roles: instance.roles,
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
toast.error("Error creating service instance");
|
||||||
|
console.error("Error creating service instance", result.errors);
|
||||||
|
}
|
||||||
|
toast.success("Created");
|
||||||
|
setShowService(false);
|
||||||
|
setWorldMode("select");
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(worldMode, (mode) => {
|
||||||
|
if (mode === "service") {
|
||||||
|
setShowService(true);
|
||||||
|
} else {
|
||||||
|
// TODO: request soft close instead of forced close
|
||||||
|
setShowService(false);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={loadingError()}>
|
<Show when={loadingError()}>
|
||||||
<ListClansModal error={loadingError()} />
|
<ListClansModal error={loadingError()} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showModal()}>
|
<Show when={ctx.showAddMachine()}>
|
||||||
<MockCreateMachine
|
<AddMachine
|
||||||
onClose={() => {
|
onCreated={async (id) => {
|
||||||
setShowModal(false);
|
const promise = currentPromise();
|
||||||
dialogHandlers()?.reject(new Error("User cancelled"));
|
if (promise) {
|
||||||
}}
|
await ctx.machinesQuery.refetch();
|
||||||
onSubmit={async (values) => {
|
promise.resolve({ id });
|
||||||
try {
|
setCurrentPromise(null);
|
||||||
const result = await sendCreate(values);
|
|
||||||
dialogHandlers()?.resolve(result);
|
|
||||||
setShowModal(false);
|
|
||||||
} catch (err) {
|
|
||||||
dialogHandlers()?.reject(err);
|
|
||||||
setShowModal(false);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
ctx.setShowAddMachine(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
@@ -301,22 +267,39 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
onSelect={onMachineSelect}
|
onSelect={onMachineSelect}
|
||||||
isLoading={ctx.isLoading()}
|
isLoading={ctx.isLoading()}
|
||||||
cubesQuery={ctx.machinesQuery}
|
cubesQuery={ctx.machinesQuery}
|
||||||
|
toolbarPopup={
|
||||||
|
<Show when={showService()}>
|
||||||
|
<ServiceWorkflow
|
||||||
|
handleSubmit={handleSubmitService}
|
||||||
|
onClose={() => {
|
||||||
|
setShowService(false);
|
||||||
|
setWorldMode("select");
|
||||||
|
currentPromise()?.resolve({ id: "0" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
|
clanURI={ctx.clanURI}
|
||||||
sceneStore={() => store.sceneData?.[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);
|
console.log("calling setStore", machineId, pos);
|
||||||
setStore(
|
setStore(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
if (!s.sceneData) {
|
if (!s.sceneData) s.sceneData = {};
|
||||||
s.sceneData = {};
|
|
||||||
}
|
if (!s.sceneData[ctx.clanURI]) s.sceneData[ctx.clanURI] = {};
|
||||||
if (!s.sceneData[ctx.clanURI]) {
|
|
||||||
s.sceneData[ctx.clanURI] = {};
|
if (pos === null) {
|
||||||
}
|
// Remove the machine entry if pos is null
|
||||||
if (!s.sceneData[ctx.clanURI][machineId]) {
|
Reflect.deleteProperty(s.sceneData[ctx.clanURI], machineId);
|
||||||
s.sceneData[ctx.clanURI][machineId] = { position: pos };
|
|
||||||
|
if (Object.keys(s.sceneData[ctx.clanURI]).length === 0) {
|
||||||
|
Reflect.deleteProperty(s.sceneData, ctx.clanURI);
|
||||||
|
}
|
||||||
} else {
|
} 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 {
|
.sidebarPaneContainer {
|
||||||
@apply absolute left-[16.5rem] top-12 w-64;
|
@apply absolute left-[16.5rem] top-4 w-64 z-20;
|
||||||
@apply min-h-96;
|
|
||||||
|
|
||||||
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 { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||||
|
|
||||||
import cx from "classnames";
|
|
||||||
import styles from "./Machine.module.css";
|
import styles from "./Machine.module.css";
|
||||||
|
|
||||||
export const Machine = (props: RouteSectionProps) => {
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
@@ -21,7 +20,8 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
navigateToClan(navigate, clanURI);
|
navigateToClan(navigate, clanURI);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sidebarPane = (machineName: string) => {
|
const sections = () => {
|
||||||
|
const machineName = useMachineName();
|
||||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||||
|
|
||||||
// we have to update the whole machine model rather than just the sub fields that were changed
|
// we have to update the whole machine model rather than just the sub fields that were changed
|
||||||
@@ -52,24 +52,35 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarPane
|
<>
|
||||||
class={cx(styles.sidebarPane)}
|
<SidebarSectionInstall
|
||||||
title={machineName}
|
clanURI={clanURI}
|
||||||
onClose={onClose}
|
machineName={useMachineName()}
|
||||||
subHeader={() => (
|
/>
|
||||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
|
||||||
<SectionGeneral {...sectionProps} />
|
<SectionGeneral {...sectionProps} />
|
||||||
<SectionTags {...sectionProps} />
|
<SectionTags {...sectionProps} />
|
||||||
</SidebarPane>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={useMachineName()} keyed>
|
<Show when={useMachineName()}>
|
||||||
{sidebarPane(useMachineName())}
|
<div class={styles.sidebarPaneContainer}>
|
||||||
|
<SidebarPane
|
||||||
|
title={useMachineName()}
|
||||||
|
onClose={onClose}
|
||||||
|
subHeader={
|
||||||
|
<Show when={useMachineName()} keyed>
|
||||||
|
<SidebarMachineStatus
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineName={useMachineName()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sections()}
|
||||||
|
</SidebarPane>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const welcome = (props: {
|
|||||||
loading={loading()}
|
loading={loading()}
|
||||||
onClick={selectFolder}
|
onClick={selectFolder}
|
||||||
>
|
>
|
||||||
Select folder
|
Select existing Clan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { SceneData } from "../stores/clan";
|
|||||||
import { MachinesQueryResult } from "../hooks/queries";
|
import { MachinesQueryResult } from "../hooks/queries";
|
||||||
import { ObjectRegistry } from "./ObjectRegistry";
|
import { ObjectRegistry } from "./ObjectRegistry";
|
||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
|
import { highlightGroups } from "./highlightStore";
|
||||||
|
|
||||||
function keyFromPos(pos: [number, number]): string {
|
function keyFromPos(pos: [number, number]): string {
|
||||||
return `${pos[0]},${pos[1]}`;
|
return `${pos[0]},${pos[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUBE_SPACING = 2;
|
const CUBE_SPACING = 1;
|
||||||
|
|
||||||
export class MachineManager {
|
export class MachineManager {
|
||||||
public machines = new Map<string, MachineRepr>();
|
public machines = new Map<string, MachineRepr>();
|
||||||
@@ -25,50 +26,75 @@ export class MachineManager {
|
|||||||
machinePositionsSignal: Accessor<SceneData>,
|
machinePositionsSignal: Accessor<SceneData>,
|
||||||
machinesQueryResult: MachinesQueryResult,
|
machinesQueryResult: MachinesQueryResult,
|
||||||
selectedIds: Accessor<Set<string>>,
|
selectedIds: Accessor<Set<string>>,
|
||||||
setMachinePos: (id: string, position: [number, number]) => void,
|
setMachinePos: (id: string, position: [number, number] | null) => void,
|
||||||
|
camera: THREE.Camera,
|
||||||
) {
|
) {
|
||||||
this.machinePositionsSignal = machinePositionsSignal;
|
this.machinePositionsSignal = machinePositionsSignal;
|
||||||
|
|
||||||
this.disposeRoot = createRoot((disposeEffects) => {
|
this.disposeRoot = createRoot((disposeEffects) => {
|
||||||
createEffect(() => {
|
//
|
||||||
const machines = machinePositionsSignal();
|
// Effect 1: sync query → store (positions)
|
||||||
|
//
|
||||||
Object.entries(machines).forEach(([id, data]) => {
|
|
||||||
const machineRepr = new MachineRepr(
|
|
||||||
scene,
|
|
||||||
registry,
|
|
||||||
new THREE.Vector2(data.position[0], data.position[1]),
|
|
||||||
id,
|
|
||||||
selectedIds,
|
|
||||||
);
|
|
||||||
this.machines.set(id, machineRepr);
|
|
||||||
scene.add(machineRepr.group);
|
|
||||||
});
|
|
||||||
renderLoop.requestRender();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push positions of previously existing machines to the scene
|
|
||||||
// TODO: Maybe we should do this in some post query hook?
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!machinesQueryResult.data) return;
|
if (!machinesQueryResult.data) return;
|
||||||
|
|
||||||
const actualMachines = Object.keys(machinesQueryResult.data);
|
const actualIds = Object.keys(machinesQueryResult.data);
|
||||||
const machinePositions = machinePositionsSignal();
|
const machinePositions = machinePositionsSignal();
|
||||||
const placed: Set<string> = machinePositions
|
// Remove stale
|
||||||
? new Set(Object.keys(machinePositions))
|
for (const id of Object.keys(machinePositions)) {
|
||||||
: new Set();
|
if (!actualIds.includes(id)) {
|
||||||
|
console.log("Removing stale machine", id);
|
||||||
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
|
setMachinePos(id, null);
|
||||||
|
}
|
||||||
// Push not explizitly placed machines to the scene
|
|
||||||
// TODO: Make the user place them manually
|
|
||||||
// We just calculate some next free position
|
|
||||||
for (const id of nonPlaced) {
|
|
||||||
console.log("adding", id);
|
|
||||||
const position = this.nextGridPos();
|
|
||||||
|
|
||||||
setMachinePos(id, position);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add missing
|
||||||
|
for (const id of actualIds) {
|
||||||
|
if (!machinePositions[id]) {
|
||||||
|
const pos = this.nextGridPos();
|
||||||
|
setMachinePos(id, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Effect 2: sync store → scene
|
||||||
|
//
|
||||||
|
createEffect(() => {
|
||||||
|
const positions = machinePositionsSignal();
|
||||||
|
if (!positions) return;
|
||||||
|
|
||||||
|
// Remove machines from scene
|
||||||
|
for (const [id, repr] of this.machines) {
|
||||||
|
if (!Object.keys(positions).includes(id)) {
|
||||||
|
repr.dispose(scene);
|
||||||
|
this.machines.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update machines
|
||||||
|
for (const [id, data] of Object.entries(positions)) {
|
||||||
|
let repr = this.machines.get(id);
|
||||||
|
if (!repr) {
|
||||||
|
repr = new MachineRepr(
|
||||||
|
scene,
|
||||||
|
registry,
|
||||||
|
new THREE.Vector2(data.position[0], data.position[1]),
|
||||||
|
id,
|
||||||
|
selectedIds,
|
||||||
|
highlightGroups,
|
||||||
|
camera,
|
||||||
|
);
|
||||||
|
this.machines.set(id, repr);
|
||||||
|
scene.add(repr.group);
|
||||||
|
} else {
|
||||||
|
repr.setPosition(
|
||||||
|
new THREE.Vector2(data.position[0], data.position[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoop.requestRender();
|
||||||
});
|
});
|
||||||
|
|
||||||
return disposeEffects;
|
return disposeEffects;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { ObjectRegistry } from "./ObjectRegistry";
|
|||||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
|
// @ts-expect-error: No types for troika-three-text
|
||||||
|
import { Text } from "troika-three-text";
|
||||||
|
import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const BASE_SIZE = 0.9;
|
const BASE_SIZE = 0.9;
|
||||||
@@ -13,6 +16,7 @@ const CUBE_COLOR = 0xe2eff0;
|
|||||||
const CUBE_EMISSIVE = 0x303030;
|
const CUBE_EMISSIVE = 0x303030;
|
||||||
|
|
||||||
const CUBE_SELECTED_COLOR = 0x4b6767;
|
const CUBE_SELECTED_COLOR = 0x4b6767;
|
||||||
|
const HIGHLIGHT_COLOR = 0x00ee66;
|
||||||
|
|
||||||
const BASE_COLOR = 0xdbeaeb;
|
const BASE_COLOR = 0xdbeaeb;
|
||||||
const BASE_EMISSIVE = 0x0c0c0c;
|
const BASE_EMISSIVE = 0x0c0c0c;
|
||||||
@@ -27,6 +31,7 @@ export class MachineRepr {
|
|||||||
private baseMesh: THREE.Mesh;
|
private baseMesh: THREE.Mesh;
|
||||||
private geometry: THREE.BoxGeometry;
|
private geometry: THREE.BoxGeometry;
|
||||||
private material: THREE.MeshPhongMaterial;
|
private material: THREE.MeshPhongMaterial;
|
||||||
|
private camera: THREE.Camera;
|
||||||
|
|
||||||
private disposeRoot: () => void;
|
private disposeRoot: () => void;
|
||||||
|
|
||||||
@@ -36,8 +41,11 @@ export class MachineRepr {
|
|||||||
position: THREE.Vector2,
|
position: THREE.Vector2,
|
||||||
id: string,
|
id: string,
|
||||||
selectedSignal: Accessor<Set<string>>,
|
selectedSignal: Accessor<Set<string>>,
|
||||||
|
highlightGroups: Record<string, Set<string>>, // Reactive store
|
||||||
|
camera: THREE.Camera,
|
||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
this.camera = camera;
|
||||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||||
this.material = new THREE.MeshPhongMaterial({
|
this.material = new THREE.MeshPhongMaterial({
|
||||||
color: CUBE_COLOR,
|
color: CUBE_COLOR,
|
||||||
@@ -60,7 +68,6 @@ export class MachineRepr {
|
|||||||
this.baseMesh.name = "base";
|
this.baseMesh.name = "base";
|
||||||
|
|
||||||
const label = this.createLabel(id);
|
const label = this.createLabel(id);
|
||||||
this.cubeMesh.add(label);
|
|
||||||
|
|
||||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||||
color: BASE_COLOR, // any color you like
|
color: BASE_COLOR, // any color you like
|
||||||
@@ -80,6 +87,7 @@ export class MachineRepr {
|
|||||||
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
|
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
|
||||||
|
|
||||||
this.group = new THREE.Group();
|
this.group = new THREE.Group();
|
||||||
|
this.group.add(label);
|
||||||
this.group.add(this.cubeMesh);
|
this.group.add(this.cubeMesh);
|
||||||
this.group.add(this.baseMesh);
|
this.group.add(this.baseMesh);
|
||||||
this.group.add(shadowPlane);
|
this.group.add(shadowPlane);
|
||||||
@@ -89,23 +97,38 @@ export class MachineRepr {
|
|||||||
|
|
||||||
this.disposeRoot = createRoot((disposeEffects) => {
|
this.disposeRoot = createRoot((disposeEffects) => {
|
||||||
createEffect(
|
createEffect(
|
||||||
on(selectedSignal, (selectedIds) => {
|
on(
|
||||||
const isSelected = selectedIds.has(this.id);
|
[selectedSignal, () => Object.entries(highlightGroups)],
|
||||||
// Update cube
|
([selectedIds, groups]) => {
|
||||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
const isSelected = selectedIds.has(this.id);
|
||||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
const highlightedGroups = groups
|
||||||
);
|
.filter(([, ids]) => ids.has(this.id))
|
||||||
|
.map(([name]) => name);
|
||||||
|
|
||||||
// Update base
|
// console.log("MachineRepr effect", id, highlightedGroups);
|
||||||
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
// Update cube
|
||||||
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||||
);
|
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
);
|
||||||
isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
|
||||||
);
|
|
||||||
|
|
||||||
renderLoop.requestRender();
|
// Update base
|
||||||
}),
|
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||||
|
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TOOD: Find a different way to show both selected & highlighted
|
||||||
|
// I.e. via outline or pulsing
|
||||||
|
// selected > highlighted > normal
|
||||||
|
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
|
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||||
|
);
|
||||||
|
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
|
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||||
|
// );
|
||||||
|
|
||||||
|
renderLoop.requestRender();
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return disposeEffects;
|
return disposeEffects;
|
||||||
@@ -121,6 +144,11 @@ export class MachineRepr {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setPosition(position: THREE.Vector2) {
|
||||||
|
this.group.position.set(position.x, 0, position.y);
|
||||||
|
renderLoop.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
private createCubeBase(
|
private createCubeBase(
|
||||||
color: THREE.ColorRepresentation,
|
color: THREE.ColorRepresentation,
|
||||||
emissive: THREE.ColorRepresentation,
|
emissive: THREE.ColorRepresentation,
|
||||||
@@ -139,12 +167,27 @@ export class MachineRepr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createLabel(id: string) {
|
private createLabel(id: string) {
|
||||||
const div = document.createElement("div");
|
const text = new Text();
|
||||||
div.className = "machine-label";
|
text.text = id;
|
||||||
div.textContent = id;
|
text.font = ttf;
|
||||||
const label = new CSS2DObject(div);
|
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
|
||||||
label.position.set(0, CUBE_SIZE + 0.1, 0);
|
text.fontSize = 0.15; // relative to your cube size
|
||||||
return label;
|
text.color = 0x000000; // any THREE.Color
|
||||||
|
text.anchorX = "center"; // horizontal centering
|
||||||
|
text.anchorY = "bottom"; // baseline aligns to cube top
|
||||||
|
text.position.set(0, CUBE_SIZE + 0.05, 0);
|
||||||
|
|
||||||
|
// If you want it to always face camera:
|
||||||
|
text.userData.isLabel = true;
|
||||||
|
text.outlineWidth = 0.005;
|
||||||
|
text.outlineColor = 0x333333;
|
||||||
|
text.quaternion.copy(this.camera.quaternion);
|
||||||
|
|
||||||
|
// Re-render on text changes
|
||||||
|
text.sync(() => {
|
||||||
|
renderLoop.requestRender();
|
||||||
|
});
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(scene: THREE.Scene) {
|
dispose(scene: THREE.Scene) {
|
||||||
@@ -154,6 +197,14 @@ export class MachineRepr {
|
|||||||
|
|
||||||
this.geometry.dispose();
|
this.geometry.dispose();
|
||||||
this.material.dispose();
|
this.material.dispose();
|
||||||
|
for (const child of this.cubeMesh.children) {
|
||||||
|
if (child instanceof THREE.Mesh)
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
|
||||||
|
if (child instanceof CSS2DObject) child.element.remove();
|
||||||
|
|
||||||
|
if (child instanceof THREE.Object3D) child.remove();
|
||||||
|
}
|
||||||
(this.baseMesh.material as THREE.Material).dispose();
|
(this.baseMesh.material as THREE.Material).dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Scene, Camera, WebGLRenderer } from "three";
|
import { Scene, Camera, WebGLRenderer } from "three";
|
||||||
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
|
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
|
||||||
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||||
|
import * as THREE from "three";
|
||||||
/**
|
/**
|
||||||
* Private class to manage the render loop
|
* Private class to manage the render loop
|
||||||
* @internal
|
* @internal
|
||||||
@@ -93,6 +93,18 @@ class RenderLoop {
|
|||||||
|
|
||||||
this.renderer.render(this.bgScene, this.bgCamera);
|
this.renderer.render(this.bgScene, this.bgCamera);
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
|
||||||
|
this.scene.traverse((obj) => {
|
||||||
|
if (obj.userData.isLabel) {
|
||||||
|
(obj as THREE.Mesh).quaternion.copy(this.camera.quaternion);
|
||||||
|
}
|
||||||
|
// if (obj.userData.isLabel) {
|
||||||
|
// const camPos = new THREE.Vector3();
|
||||||
|
// this.camera.getWorldPosition(camPos);
|
||||||
|
// obj.lookAt(new THREE.Vector3(camPos.x, obj.position.y, camPos.z));
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
|
||||||
this.labelRenderer.render(this.scene, this.camera);
|
this.labelRenderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
.cubes-scene-container {
|
.cubes-scene-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||||
|
<Show when={show()}> */
|
||||||
.toolbar-container {
|
.toolbar-container {
|
||||||
@apply absolute bottom-10 z-10 w-full;
|
@apply absolute bottom-10 z-10 w-full;
|
||||||
@apply flex justify-center items-center;
|
@apply flex justify-center items-center;
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
|
import {
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
on,
|
||||||
|
JSX,
|
||||||
|
Show,
|
||||||
|
} from "solid-js";
|
||||||
import "./cubes.css";
|
import "./cubes.css";
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
@@ -8,12 +16,36 @@ import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
|||||||
import { Toolbar } from "../components/Toolbar/Toolbar";
|
import { Toolbar } from "../components/Toolbar/Toolbar";
|
||||||
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
|
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
|
||||||
import { Divider } from "../components/Divider/Divider";
|
import { Divider } from "../components/Divider/Divider";
|
||||||
import { MachinesQueryResult } from "../hooks/queries";
|
import { MachinesQueryResult, useMachinesQuery } from "../hooks/queries";
|
||||||
import { SceneData } from "../stores/clan";
|
import { SceneData } from "../stores/clan";
|
||||||
import { Accessor } from "solid-js";
|
import { Accessor } from "solid-js";
|
||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
import { ObjectRegistry } from "./ObjectRegistry";
|
import { ObjectRegistry } from "./ObjectRegistry";
|
||||||
import { MachineManager } from "./MachineManager";
|
import { MachineManager } from "./MachineManager";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
|
import { Menu } from "../components/ContextMenu/ContextMenu";
|
||||||
|
import { clearHighlight, setHighlightGroups } from "./highlightStore";
|
||||||
|
|
||||||
|
function intersectMachines(
|
||||||
|
event: MouseEvent,
|
||||||
|
renderer: THREE.WebGLRenderer,
|
||||||
|
camera: THREE.Camera,
|
||||||
|
machineManager: MachineManager,
|
||||||
|
raycaster: THREE.Raycaster,
|
||||||
|
): string[] {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
const mouse = new THREE.Vector2(
|
||||||
|
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||||
|
);
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(
|
||||||
|
Array.from(machineManager.machines.values().map((m) => m.group)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersects.map((i) => i.object.userData.id);
|
||||||
|
}
|
||||||
|
|
||||||
function garbageCollectGroup(group: THREE.Group) {
|
function garbageCollectGroup(group: THREE.Group) {
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
@@ -31,14 +63,45 @@ function garbageCollectGroup(group: THREE.Group) {
|
|||||||
group.clear(); // Clear the group
|
group.clear(); // Clear the group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Can be imported by others via wrappers below
|
||||||
|
// Global signal for last clicked machine
|
||||||
|
const [lastClickedMachine, setLastClickedMachine] = createSignal<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exported so others could also emit the signal if needed
|
||||||
|
// And for testing purposes
|
||||||
|
export function emitMachineClick(id: string | null) {
|
||||||
|
setLastClickedMachine(id);
|
||||||
|
if (id) {
|
||||||
|
// Clear after a short delay to allow re-clicking the same machine
|
||||||
|
setTimeout(() => {
|
||||||
|
setLastClickedMachine(null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook for components to subscribe */
|
||||||
|
export function useMachineClick() {
|
||||||
|
return lastClickedMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*Gloabl signal*/
|
||||||
|
const [worldMode, setWorldMode] = createSignal<
|
||||||
|
"default" | "select" | "service" | "create" | "move"
|
||||||
|
>("select");
|
||||||
|
export { worldMode, setWorldMode };
|
||||||
|
|
||||||
export function CubeScene(props: {
|
export function CubeScene(props: {
|
||||||
cubesQuery: MachinesQueryResult;
|
cubesQuery: MachinesQueryResult;
|
||||||
onCreate: () => Promise<{ id: string }>;
|
onCreate: () => Promise<{ id: string }>;
|
||||||
selectedIds: Accessor<Set<string>>;
|
selectedIds: Accessor<Set<string>>;
|
||||||
onSelect: (v: Set<string>) => void;
|
onSelect: (v: Set<string>) => void;
|
||||||
sceneStore: Accessor<SceneData>;
|
sceneStore: Accessor<SceneData>;
|
||||||
setMachinePos: (machineId: string, pos: [number, number]) => void;
|
setMachinePos: (machineId: string, pos: [number, number] | null) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
clanURI: string;
|
||||||
|
toolbarPopup?: JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let scene: THREE.Scene;
|
let scene: THREE.Scene;
|
||||||
@@ -49,7 +112,7 @@ export function CubeScene(props: {
|
|||||||
let controls: MapControls;
|
let controls: MapControls;
|
||||||
// Raycaster for clicking
|
// Raycaster for clicking
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
let initBase: THREE.Mesh | undefined;
|
let actionBase: THREE.Mesh | undefined;
|
||||||
|
|
||||||
// Create background scene
|
// Create background scene
|
||||||
const bgScene = new THREE.Scene();
|
const bgScene = new THREE.Scene();
|
||||||
@@ -63,8 +126,8 @@ export function CubeScene(props: {
|
|||||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||||
"grid",
|
"grid",
|
||||||
);
|
);
|
||||||
|
// Managed by controls
|
||||||
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||||
|
|
||||||
@@ -72,9 +135,13 @@ export function CubeScene(props: {
|
|||||||
position: { x: 0, y: 0, z: 0 },
|
position: { x: 0, y: 0, z: 0 },
|
||||||
spherical: { radius: 0, theta: 0, phi: 0 },
|
spherical: { radius: 0, theta: 0, phi: 0 },
|
||||||
});
|
});
|
||||||
|
// Context menu state
|
||||||
|
const [contextOpen, setContextOpen] = createSignal(false);
|
||||||
|
const [menuPos, setMenuPos] = createSignal<{ x: number; y: number }>();
|
||||||
|
const [menuIntersection, setMenuIntersection] = createSignal<string[]>([]);
|
||||||
|
|
||||||
// Grid configuration
|
// Grid configuration
|
||||||
const GRID_SIZE = 2;
|
const GRID_SIZE = 1;
|
||||||
|
|
||||||
const BASE_SIZE = 0.9; // Height of the cube above the ground
|
const BASE_SIZE = 0.9; // Height of the cube above the ground
|
||||||
const CUBE_SIZE = BASE_SIZE / 1.5; //
|
const CUBE_SIZE = BASE_SIZE / 1.5; //
|
||||||
@@ -87,8 +154,10 @@ export function CubeScene(props: {
|
|||||||
const BASE_COLOR = 0xecfdff;
|
const BASE_COLOR = 0xecfdff;
|
||||||
const BASE_EMISSIVE = 0x0c0c0c;
|
const BASE_EMISSIVE = 0x0c0c0c;
|
||||||
|
|
||||||
const CREATE_BASE_COLOR = 0x636363;
|
const ACTION_BASE_COLOR = 0x636363;
|
||||||
|
|
||||||
const CREATE_BASE_EMISSIVE = 0xc5fad7;
|
const CREATE_BASE_EMISSIVE = 0xc5fad7;
|
||||||
|
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
|
||||||
|
|
||||||
function createCubeBase(
|
function createCubeBase(
|
||||||
cube_pos: [number, number, number],
|
cube_pos: [number, number, number],
|
||||||
@@ -109,12 +178,6 @@ export function CubeScene(props: {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelection(id: string) {
|
|
||||||
const next = new Set<string>();
|
|
||||||
next.add(id);
|
|
||||||
props.onSelect(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialCameraPosition = { x: 20, y: 20, z: 20 };
|
const initialCameraPosition = { x: 20, y: 20, z: 20 };
|
||||||
const initialSphericalCameraPosition = new THREE.Spherical();
|
const initialSphericalCameraPosition = new THREE.Spherical();
|
||||||
initialSphericalCameraPosition.setFromVector3(
|
initialSphericalCameraPosition.setFromVector3(
|
||||||
@@ -212,7 +275,8 @@ export function CubeScene(props: {
|
|||||||
controls = new MapControls(camera, renderer.domElement);
|
controls = new MapControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
|
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
|
||||||
controls.mouseButtons.RIGHT = null;
|
controls.mouseButtons.RIGHT = null;
|
||||||
controls.enableRotate = false;
|
// controls.rotateSpeed = -0.8;
|
||||||
|
// controls.enableRotate = false;
|
||||||
controls.minZoom = 1.2;
|
controls.minZoom = 1.2;
|
||||||
controls.maxZoom = 3.5;
|
controls.maxZoom = 3.5;
|
||||||
controls.addEventListener("change", () => {
|
controls.addEventListener("change", () => {
|
||||||
@@ -236,6 +300,13 @@ export function CubeScene(props: {
|
|||||||
bgCamera,
|
bgCamera,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
controls.addEventListener("start", (e) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
});
|
||||||
|
controls.addEventListener("end", (e) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
});
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||||
scene.add(ambientLight);
|
scene.add(ambientLight);
|
||||||
@@ -303,15 +374,15 @@ export function CubeScene(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Important create CubeBase depends on sharedBaseGeometry
|
// Important create CubeBase depends on sharedBaseGeometry
|
||||||
initBase = createCubeBase(
|
actionBase = createCubeBase(
|
||||||
[1, BASE_HEIGHT / 2, 1],
|
[1, BASE_HEIGHT / 2, 1],
|
||||||
1,
|
1,
|
||||||
CREATE_BASE_COLOR,
|
ACTION_BASE_COLOR,
|
||||||
CREATE_BASE_EMISSIVE,
|
CREATE_BASE_EMISSIVE,
|
||||||
);
|
);
|
||||||
initBase.visible = false;
|
actionBase.visible = false;
|
||||||
|
|
||||||
scene.add(initBase);
|
scene.add(actionBase);
|
||||||
|
|
||||||
// const spherical = new THREE.Spherical();
|
// const spherical = new THREE.Spherical();
|
||||||
// spherical.setFromVector3(camera.position);
|
// spherical.setFromVector3(camera.position);
|
||||||
@@ -340,9 +411,9 @@ export function CubeScene(props: {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(worldMode, (mode) => {
|
on(worldMode, (mode) => {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
initBase!.visible = true;
|
actionBase!.visible = true;
|
||||||
} else {
|
} else {
|
||||||
initBase!.visible = false;
|
actionBase!.visible = false;
|
||||||
}
|
}
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}),
|
}),
|
||||||
@@ -357,10 +428,11 @@ export function CubeScene(props: {
|
|||||||
props.cubesQuery,
|
props.cubesQuery,
|
||||||
props.selectedIds,
|
props.selectedIds,
|
||||||
props.setMachinePos,
|
props.setMachinePos,
|
||||||
|
camera,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click handler:
|
// Click handler:
|
||||||
// - Select/deselects a cube in "view" mode
|
// - Select/deselects a cube in mode
|
||||||
// - Creates a new cube in "create" mode
|
// - Creates a new cube in "create" mode
|
||||||
const onClick = (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (worldMode() === "create") {
|
if (worldMode() === "create") {
|
||||||
@@ -379,11 +451,21 @@ export function CubeScene(props: {
|
|||||||
console.error("Error creating cube:", error);
|
console.error("Error creating cube:", error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (initBase) initBase.visible = false;
|
if (actionBase) actionBase.visible = false;
|
||||||
|
|
||||||
setWorldMode("view");
|
setWorldMode("default");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (worldMode() === "move") {
|
||||||
|
console.log("sanpped");
|
||||||
|
const currId = menuIntersection().at(0);
|
||||||
|
const pos = cursorPosition();
|
||||||
|
if (!currId || !pos) return;
|
||||||
|
|
||||||
|
props.setMachinePos(currId, pos);
|
||||||
|
setWorldMode("select");
|
||||||
|
clearHighlight("move");
|
||||||
|
}
|
||||||
|
|
||||||
const rect = renderer.domElement.getBoundingClientRect();
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
const mouse = new THREE.Vector2(
|
const mouse = new THREE.Vector2(
|
||||||
@@ -399,9 +481,14 @@ export function CubeScene(props: {
|
|||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
console.log("Clicked on cube:", intersects);
|
console.log("Clicked on cube:", intersects);
|
||||||
const id = intersects[0].object.userData.id;
|
const id = intersects[0].object.userData.id;
|
||||||
toggleSelection(id);
|
|
||||||
|
if (worldMode() === "select") props.onSelect(new Set<string>([id]));
|
||||||
|
|
||||||
|
emitMachineClick(id); // notify subscribers
|
||||||
} else {
|
} else {
|
||||||
props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
|
emitMachineClick(null);
|
||||||
|
|
||||||
|
if (worldMode() === "select") props.onSelect(new Set<string>());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -432,18 +519,28 @@ export function CubeScene(props: {
|
|||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (e.button === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const intersection = intersectMachines(
|
||||||
|
e,
|
||||||
|
renderer,
|
||||||
|
camera,
|
||||||
|
machineManager,
|
||||||
|
raycaster,
|
||||||
|
);
|
||||||
|
if (!intersection.length) return;
|
||||||
|
setMenuIntersection(intersection);
|
||||||
|
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||||
|
setContextOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
||||||
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
// For debugging,
|
|
||||||
// TODO: Remove in production
|
|
||||||
window.addEventListener(
|
|
||||||
"contextmenu",
|
|
||||||
(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
{ capture: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
@@ -470,12 +567,12 @@ export function CubeScene(props: {
|
|||||||
renderer.domElement.removeEventListener("mousemove", onMouseMove);
|
renderer.domElement.removeEventListener("mousemove", onMouseMove);
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
|
|
||||||
if (initBase) {
|
if (actionBase) {
|
||||||
initBase.geometry.dispose();
|
actionBase.geometry.dispose();
|
||||||
if (Array.isArray(initBase.material)) {
|
if (Array.isArray(actionBase.material)) {
|
||||||
initBase.material.forEach((material) => material.dispose());
|
actionBase.material.forEach((material) => material.dispose());
|
||||||
} else {
|
} else {
|
||||||
initBase.material.dispose();
|
actionBase.material.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +588,18 @@ export function CubeScene(props: {
|
|||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
};
|
};
|
||||||
const onMouseMove = (event: MouseEvent) => {
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
if (worldMode() !== "create") return;
|
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
||||||
if (!initBase) return;
|
if (!actionBase) return;
|
||||||
|
|
||||||
initBase.visible = true;
|
console.log("Mouse move in create/move mode");
|
||||||
|
|
||||||
|
actionBase.visible = true;
|
||||||
|
(actionBase.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
|
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate mouse position in normalized device coordinates
|
||||||
|
// (-1 to +1) for both components
|
||||||
|
|
||||||
const rect = renderer.domElement.getBoundingClientRect();
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
const mouse = new THREE.Vector2(
|
const mouse = new THREE.Vector2(
|
||||||
@@ -505,6 +610,7 @@ export function CubeScene(props: {
|
|||||||
const intersects = raycaster.intersectObject(floor);
|
const intersects = raycaster.intersectObject(floor);
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
const point = intersects[0].point;
|
const point = intersects[0].point;
|
||||||
|
|
||||||
// Snap to grid
|
// Snap to grid
|
||||||
const snapped = new THREE.Vector3(
|
const snapped = new THREE.Vector3(
|
||||||
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
|
||||||
@@ -512,35 +618,76 @@ export function CubeScene(props: {
|
|||||||
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
|
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Skip snapping if there's already a cube at this position
|
||||||
|
if (props.sceneStore()) {
|
||||||
|
const positions = Object.values(props.sceneStore());
|
||||||
|
const intersects = positions.some(
|
||||||
|
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
|
||||||
|
);
|
||||||
|
if (intersects) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Math.abs(initBase.position.x - snapped.x) > 0.01 ||
|
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
|
||||||
Math.abs(initBase.position.z - snapped.z) > 0.01
|
Math.abs(actionBase.position.z - snapped.z) > 0.01
|
||||||
) {
|
) {
|
||||||
// Only request render if the position actually changed
|
// Only request render if the position actually changed
|
||||||
initBase.position.set(snapped.x, 0, snapped.z);
|
actionBase.position.set(snapped.x, 0, snapped.z);
|
||||||
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleMenuSelect = (mode: "move") => {
|
||||||
|
setWorldMode(mode);
|
||||||
|
setHighlightGroups({ move: new Set(menuIntersection()) });
|
||||||
|
console.log("Menu selected, new World mode", worldMode());
|
||||||
|
};
|
||||||
|
|
||||||
|
const machinesQuery = useMachinesQuery(props.clanURI);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="cubes-scene-container" ref={(el) => (container = el)} />
|
<Show when={contextOpen()}>
|
||||||
|
<Portal mount={document.body}>
|
||||||
|
<Menu
|
||||||
|
onSelect={handleMenuSelect}
|
||||||
|
intersect={menuIntersection()}
|
||||||
|
x={menuPos()!.x - 10}
|
||||||
|
y={menuPos()!.y - 10}
|
||||||
|
close={() => setContextOpen(false)}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"cubes-scene-container",
|
||||||
|
worldMode() === "default" && "cursor-no-drop",
|
||||||
|
worldMode() === "select" && "cursor-pointer",
|
||||||
|
worldMode() === "service" && "cursor-pointer",
|
||||||
|
worldMode() === "create" && "cursor-cell",
|
||||||
|
isDragging() && "!cursor-grabbing",
|
||||||
|
)}
|
||||||
|
ref={(el) => (container = el)}
|
||||||
|
/>
|
||||||
<div class="toolbar-container">
|
<div class="toolbar-container">
|
||||||
|
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||||
|
{props.toolbarPopup}
|
||||||
|
</div>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
description="Select machine"
|
description="Select machine"
|
||||||
name="Select"
|
name="Select"
|
||||||
icon="Cursor"
|
icon="Cursor"
|
||||||
onClick={() => setWorldMode("view")}
|
onClick={() => setWorldMode("select")}
|
||||||
selected={worldMode() === "view"}
|
selected={worldMode() === "select"}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
description="Create new machine"
|
description="Create new machine"
|
||||||
name="new-machine"
|
name="new-machine"
|
||||||
icon="NewMachine"
|
icon="NewMachine"
|
||||||
disabled={positionMode() === "circle"}
|
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
selected={worldMode() === "create"}
|
selected={worldMode() === "create"}
|
||||||
/>
|
/>
|
||||||
@@ -548,24 +695,18 @@ export function CubeScene(props: {
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
description="Add new Service"
|
description="Add new Service"
|
||||||
name="modules"
|
name="modules"
|
||||||
icon="Modules"
|
icon="Services"
|
||||||
|
selected={worldMode() === "service"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (positionMode() === "grid") {
|
setWorldMode("service");
|
||||||
setPositionMode("circle");
|
|
||||||
setWorldMode("view");
|
|
||||||
grid.visible = false;
|
|
||||||
} else {
|
|
||||||
setPositionMode("grid");
|
|
||||||
grid.visible = true;
|
|
||||||
}
|
|
||||||
renderLoop.requestRender();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* <ToolbarButton
|
<ToolbarButton
|
||||||
description="Delete Machine"
|
icon="Update"
|
||||||
name="delete"
|
name="Reload"
|
||||||
icon="Trash"
|
description="Reload machines"
|
||||||
/> */}
|
onClick={() => machinesQuery.refetch()}
|
||||||
|
/>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// highlightStore.ts
|
||||||
|
import { createStore, produce } from "solid-js/store";
|
||||||
|
|
||||||
|
// groups: { [groupName: string]: Set<nodeId> }
|
||||||
|
const [highlightGroups, setHighlightGroups] = createStore<
|
||||||
|
Record<string, Set<string>>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// Add highlight
|
||||||
|
export function highlight(group: string, nodeId: string) {
|
||||||
|
setHighlightGroups(group, (prev = new Set()) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(nodeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove highlight
|
||||||
|
export function unhighlight(group: string, nodeId: string) {
|
||||||
|
setHighlightGroups(group, (prev = new Set()) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(nodeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear group
|
||||||
|
export function clearHighlight(group: string) {
|
||||||
|
setHighlightGroups(group, () => new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllHighlights() {
|
||||||
|
setHighlightGroups(
|
||||||
|
produce((s) => {
|
||||||
|
for (const key of Object.keys(s)) {
|
||||||
|
Reflect.deleteProperty(s, key);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { highlightGroups, setHighlightGroups };
|
||||||
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
MemoryRouter,
|
||||||
|
RouteDefinition,
|
||||||
|
} from "@solidjs/router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
|
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
|
||||||
|
import {
|
||||||
|
ApiCall,
|
||||||
|
OperationNames,
|
||||||
|
OperationResponse,
|
||||||
|
SuccessQuery,
|
||||||
|
} from "@/src/hooks/api";
|
||||||
|
|
||||||
|
type ResultDataMap = {
|
||||||
|
[K in OperationNames]: SuccessQuery<K>["data"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||||
|
name: K,
|
||||||
|
_args: unknown,
|
||||||
|
): ApiCall<K> => {
|
||||||
|
// TODO: Make this configurable for every story
|
||||||
|
const resultData: Partial<ResultDataMap> = {
|
||||||
|
list_machines: {
|
||||||
|
pandora: {
|
||||||
|
name: "pandora",
|
||||||
|
},
|
||||||
|
enceladus: {
|
||||||
|
name: "enceladus",
|
||||||
|
},
|
||||||
|
dione: {
|
||||||
|
name: "dione",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: "mock",
|
||||||
|
cancel: () => Promise.resolve(),
|
||||||
|
result: new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
op_key: "1",
|
||||||
|
status: "success",
|
||||||
|
data: resultData[name],
|
||||||
|
} as OperationResponse<K>);
|
||||||
|
}, 1500);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof AddMachine> = {
|
||||||
|
title: "workflows/add-machine",
|
||||||
|
component: AddMachine,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext) => {
|
||||||
|
const Routes: RouteDefinition[] = [
|
||||||
|
{
|
||||||
|
path: "/clans/:clanURI",
|
||||||
|
component: () => (
|
||||||
|
<div class="w-[600px]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const history = createMemoryHistory();
|
||||||
|
history.set({ value: "/clans/dGVzdA==", replace: true });
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiClientProvider client={{ fetch: mockFetcher }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter
|
||||||
|
root={(props) => {
|
||||||
|
console.debug("Rendering MemoryRouter root with props:", props);
|
||||||
|
return props.children;
|
||||||
|
}}
|
||||||
|
history={history}
|
||||||
|
>
|
||||||
|
{Routes}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiClientProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof AddMachine>;
|
||||||
|
|
||||||
|
export const General: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Host: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "host",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tags: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "tags",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Progress: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "progress",
|
||||||
|
},
|
||||||
|
};
|
||||||
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
createStepper,
|
||||||
|
defineSteps,
|
||||||
|
StepperProvider,
|
||||||
|
useStepper,
|
||||||
|
} from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
GeneralForm,
|
||||||
|
StepGeneral,
|
||||||
|
} from "@/src/workflows/AddMachine/StepGeneral";
|
||||||
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
|
||||||
|
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
|
||||||
|
import { StepProgress } from "./StepProgress";
|
||||||
|
|
||||||
|
interface AddMachineStepperProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddMachineStepper = (props: AddMachineStepperProps) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={stepSignal.currentStep().content}
|
||||||
|
onDone={props.onDone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AddMachineProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
initialStep?: AddMachineSteps[number]["id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMachineStoreType {
|
||||||
|
general: GeneralForm;
|
||||||
|
deploy: {
|
||||||
|
targetHost: string;
|
||||||
|
};
|
||||||
|
tags: {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = defineSteps([
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
title: "General",
|
||||||
|
content: StepGeneral,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "host",
|
||||||
|
title: "Host",
|
||||||
|
content: StepHost,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
title: "Tags",
|
||||||
|
content: StepTags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "progress",
|
||||||
|
title: "Creating...",
|
||||||
|
content: StepProgress,
|
||||||
|
isSplash: true,
|
||||||
|
},
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type AddMachineSteps = typeof steps;
|
||||||
|
|
||||||
|
export const AddMachine = (props: AddMachineProps) => {
|
||||||
|
const stepper = createStepper(
|
||||||
|
{
|
||||||
|
steps,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialStep: props.initialStep || "general",
|
||||||
|
initialStoreData: { onCreated: props.onCreated },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const MetaHeader = () => {
|
||||||
|
const title = stepper.currentStep().title;
|
||||||
|
return (
|
||||||
|
<Show when={title}>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="default"
|
||||||
|
weight="medium"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = () => {
|
||||||
|
const defaultClass = "max-w-3xl h-fit";
|
||||||
|
|
||||||
|
const currentStep = stepper.currentStep();
|
||||||
|
if (!currentStep) {
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentStep.id) {
|
||||||
|
default:
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperProvider stepper={stepper}>
|
||||||
|
<Modal
|
||||||
|
class={cx("w-screen", sizeClasses())}
|
||||||
|
title="Add Machine"
|
||||||
|
onClose={props.onClose}
|
||||||
|
open={true}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
disablePadding={stepper.currentStep()?.isSplash}
|
||||||
|
>
|
||||||
|
<AddMachineStepper onDone={() => props.onClose()} />
|
||||||
|
</Modal>
|
||||||
|
</StepperProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
clearError,
|
||||||
|
createForm,
|
||||||
|
FieldValues,
|
||||||
|
getError,
|
||||||
|
getErrors,
|
||||||
|
setError,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
|
import { TextArea } from "@/src/components/Form/TextArea";
|
||||||
|
import { Select } from "@/src/components/Select/Select";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
import { useMachinesQuery } from "@/src/hooks/queries";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const PlatformOptions = [
|
||||||
|
{ label: "NixOS", value: "nixos" },
|
||||||
|
{ label: "Darwin", value: "darwin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GeneralSchema = v.object({
|
||||||
|
name: v.pipe(
|
||||||
|
v.string("Name must be a string"),
|
||||||
|
v.nonEmpty("Please enter a machine name"),
|
||||||
|
v.regex(
|
||||||
|
new RegExp(/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/),
|
||||||
|
"Name must be a valid hostname e.g. alphanumeric characters and - only",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
description: v.optional(v.string("Description must be a string")),
|
||||||
|
machineClass: v.pipe(v.string(), v.nonEmpty()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface GeneralForm extends FieldValues {
|
||||||
|
machineClass: "nixos" | "darwin";
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepGeneral = () => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
const machines = useMachinesQuery(clanURI);
|
||||||
|
|
||||||
|
const machineNames = () => {
|
||||||
|
if (!machines.isSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(machines.data || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<GeneralForm>({
|
||||||
|
validate: valiForm(GeneralSchema),
|
||||||
|
initialValues: { ...store.general, machineClass: "nixos" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
|
||||||
|
if (machineNames().includes(values.name)) {
|
||||||
|
setError(
|
||||||
|
formStore,
|
||||||
|
"name",
|
||||||
|
`A machine named '${values.name}' already exists. Please choose a different one.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(formStore, "name");
|
||||||
|
|
||||||
|
set("general", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formError = () => {
|
||||||
|
const errors = getErrors(formStore);
|
||||||
|
return errors.name || errors.description || errors.machineClass;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Show when={formError()}>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
icon="WarningFilled"
|
||||||
|
title="Error"
|
||||||
|
description={formError()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="name">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "A unique machine name.",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "name") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Divider />
|
||||||
|
<Field name="description">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Description"
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "A short description of the machine.",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "description") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="machineClass">
|
||||||
|
{(field, props) => (
|
||||||
|
<Select
|
||||||
|
zIndex={100}
|
||||||
|
{...props}
|
||||||
|
value={field.value}
|
||||||
|
error={field.error}
|
||||||
|
required
|
||||||
|
label={{
|
||||||
|
label: "Platform",
|
||||||
|
}}
|
||||||
|
options={PlatformOptions}
|
||||||
|
name={field.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NextButton type="submit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
createForm,
|
||||||
|
getError,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
|
|
||||||
|
const HostSchema = v.object({
|
||||||
|
targetHost: v.pipe(v.string("Name must be a string")),
|
||||||
|
});
|
||||||
|
|
||||||
|
type HostForm = v.InferInput<typeof HostSchema>;
|
||||||
|
|
||||||
|
export const StepHost = () => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<HostForm>({
|
||||||
|
validate: valiForm(HostSchema),
|
||||||
|
initialValues: store.deploy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<HostForm> = (values, event) => {
|
||||||
|
set("deploy", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="targetHost">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Target"
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "root@flashinstaller.local",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "targetHost") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<NextButton type="submit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Loader } from "@/src/components/Loader/Loader";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
|
||||||
|
export interface StepProgressProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepProgress = (props: StepProgressProps) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
|
||||||
|
<Show
|
||||||
|
when={store.error}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<Loader class="size-8" />
|
||||||
|
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||||
|
{store.general?.name} is being created
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
title="There was an error"
|
||||||
|
description={store.error}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
101
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { BackButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const TagsSchema = v.object({
|
||||||
|
tags: v.array(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TagsForm = v.InferInput<typeof TagsSchema>;
|
||||||
|
|
||||||
|
export const StepTags = (props: { onDone: () => void }) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<TagsForm>({
|
||||||
|
validate: valiForm(TagsSchema),
|
||||||
|
initialValues: store.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<TagsForm> = async (values, event) => {
|
||||||
|
set("tags", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const call = apiClient.fetch("create_machine", {
|
||||||
|
opts: {
|
||||||
|
clan_dir: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
machine: {
|
||||||
|
...store.general,
|
||||||
|
...store.tags,
|
||||||
|
deploy: store.deploy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status == "error") {
|
||||||
|
// setError(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status == "success") {
|
||||||
|
console.log("Machine creation was successful");
|
||||||
|
if (store.general) {
|
||||||
|
store.onCreated(store.general.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props.onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="tags" type="string[]">
|
||||||
|
{(field, input) => (
|
||||||
|
<MachineTags
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
orientation="horizontal"
|
||||||
|
defaultValue={field.value}
|
||||||
|
defaultOptions={[]}
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<Button hierarchy="primary" type="submit" endIcon="Flash">
|
||||||
|
Create Machine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
import { InstallModal } from "./install";
|
import { InstallModal } from "./InstallMachine";
|
||||||
import {
|
import {
|
||||||
createMemoryHistory,
|
createMemoryHistory,
|
||||||
MemoryRouter,
|
MemoryRouter,
|
||||||
@@ -52,6 +52,7 @@ export interface InstallStoreType {
|
|||||||
};
|
};
|
||||||
install: {
|
install: {
|
||||||
targetHost: string;
|
targetHost: string;
|
||||||
|
port?: string;
|
||||||
machineName: string;
|
machineName: string;
|
||||||
mainDisk: string;
|
mainDisk: string;
|
||||||
// ...TODO Vars
|
// ...TODO Vars
|
||||||
@@ -96,6 +97,8 @@ export const InstallModal = (props: InstallModalProps) => {
|
|||||||
switch (currentStep.id) {
|
switch (currentStep.id) {
|
||||||
case "create:progress":
|
case "create:progress":
|
||||||
case "create:done":
|
case "create:done":
|
||||||
|
case "install:progress":
|
||||||
|
case "install:done":
|
||||||
return currentStep.class;
|
return currentStep.class;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps } from "../install";
|
import { InstallSteps } from "../InstallMachine";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { StepLayout } from "../../Steps";
|
import { StepLayout } from "../../Steps";
|
||||||
import { NavSection } from "@/src/components/NavSection/NavSection";
|
import { NavSection } from "@/src/components/NavSection/NavSection";
|
||||||
@@ -13,7 +13,7 @@ const ChoiceLocalOrRemote = () => {
|
|||||||
onClick={() => stepSignal.setActiveStep("local:choice")}
|
onClick={() => stepSignal.setActiveStep("local:choice")}
|
||||||
/>
|
/>
|
||||||
<NavSection
|
<NavSection
|
||||||
label="The Machine is remote and i have ssh access to it"
|
label="The machine is remote and I have ssh access to it"
|
||||||
onClick={() => stepSignal.setActiveStep("install:address")}
|
onClick={() => stepSignal.setActiveStep("install:address")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
valiForm,
|
valiForm,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { InstallSteps, InstallStoreType } from "../install";
|
import { InstallSteps, InstallStoreType } from "../InstallMachine";
|
||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||||
import { Select } from "@/src/components/Select/Select";
|
import { Select } from "@/src/components/Select/Select";
|
||||||
@@ -24,8 +24,8 @@ import cx from "classnames";
|
|||||||
const Prose = () => (
|
const Prose = () => (
|
||||||
<StepLayout
|
<StepLayout
|
||||||
body={
|
body={
|
||||||
<>
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md px-4 py-6 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
|
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md p-4 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
@@ -74,7 +74,7 @@ const Prose = () => (
|
|||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
footer={<StepFooter nextText="start" />}
|
footer={<StepFooter nextText="start" />}
|
||||||
/>
|
/>
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
import {
|
||||||
|
InstallSteps,
|
||||||
|
InstallStoreType,
|
||||||
|
PromptValues,
|
||||||
|
} from "../InstallMachine";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { Alert } from "@/src/components/Alert/Alert";
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||||
@@ -44,6 +48,12 @@ const ConfigureAdressSchema = v.object({
|
|||||||
v.string("Please set a target host."),
|
v.string("Please set a target host."),
|
||||||
v.nonEmpty("Please set a target host."),
|
v.nonEmpty("Please set a target host."),
|
||||||
),
|
),
|
||||||
|
port: v.optional(
|
||||||
|
v.pipe(
|
||||||
|
v.string(),
|
||||||
|
v.transform((val) => (val === "" ? undefined : val)),
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
|
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
|
||||||
@@ -56,6 +66,7 @@ const ConfigureAddress = () => {
|
|||||||
validate: valiForm(ConfigureAdressSchema),
|
validate: valiForm(ConfigureAdressSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
targetHost: store.install?.targetHost,
|
targetHost: store.install?.targetHost,
|
||||||
|
port: store.install?.port,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +80,11 @@ const ConfigureAddress = () => {
|
|||||||
event,
|
event,
|
||||||
) => {
|
) => {
|
||||||
console.log("targetHost set", values);
|
console.log("targetHost set", values);
|
||||||
set("install", (s) => ({ ...s, targetHost: values.targetHost }));
|
set("install", (s) => ({
|
||||||
|
...s,
|
||||||
|
targetHost: values.targetHost,
|
||||||
|
port: values.port,
|
||||||
|
}));
|
||||||
|
|
||||||
// Here you would typically trigger the ISO creation process
|
// Here you would typically trigger the ISO creation process
|
||||||
stepSignal.next();
|
stepSignal.next();
|
||||||
@@ -81,10 +96,18 @@ const ConfigureAddress = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const portValue = getValue(formStore, "port");
|
||||||
|
const port = portValue ? parseInt(portValue, 10) : undefined;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const call = client.fetch("check_machine_ssh_login", {
|
const call = client.fetch("check_machine_ssh_login", {
|
||||||
remote: {
|
remote: {
|
||||||
address,
|
address,
|
||||||
|
...(port && { port }),
|
||||||
|
ssh_options: {
|
||||||
|
StrictHostKeyChecking: "no",
|
||||||
|
UserKnownHostsFile: "/dev/null",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await call.result;
|
const result = await call.result;
|
||||||
@@ -121,6 +144,25 @@ const ConfigureAddress = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field name="port">
|
||||||
|
{(field, props) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
label="SSH Port"
|
||||||
|
description="SSH port (default: 22)"
|
||||||
|
value={field.value}
|
||||||
|
orientation="horizontal"
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "port") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
input={{
|
||||||
|
...props,
|
||||||
|
placeholder: "22",
|
||||||
|
type: "number",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -166,23 +208,41 @@ const CheckHardware = () => {
|
|||||||
|
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
|
|
||||||
|
const [updatingHardwareReport, setUpdatingHardwareReport] =
|
||||||
|
createSignal(false);
|
||||||
|
|
||||||
const handleUpdateSummary = async () => {
|
const handleUpdateSummary = async () => {
|
||||||
// TODO: Debounce
|
setUpdatingHardwareReport(true);
|
||||||
const call = client.fetch("run_machine_hardware_info", {
|
|
||||||
target_host: {
|
const port = store.install.port
|
||||||
address: store.install.targetHost,
|
? parseInt(store.install.port, 10)
|
||||||
},
|
: undefined;
|
||||||
opts: {
|
|
||||||
machine: {
|
try {
|
||||||
flake: {
|
// TODO: Debounce
|
||||||
identifier: clanUri,
|
const call = client.fetch("run_machine_hardware_info", {
|
||||||
|
target_host: {
|
||||||
|
address: store.install.targetHost,
|
||||||
|
...(port && { port }),
|
||||||
|
ssh_options: {
|
||||||
|
StrictHostKeyChecking: "no",
|
||||||
|
UserKnownHostsFile: "/dev/null",
|
||||||
},
|
},
|
||||||
name: store.install.machineName,
|
|
||||||
},
|
},
|
||||||
},
|
opts: {
|
||||||
});
|
machine: {
|
||||||
await call.result;
|
flake: {
|
||||||
hardwareQuery.refetch();
|
identifier: clanUri,
|
||||||
|
},
|
||||||
|
name: store.install.machineName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await call.result;
|
||||||
|
await hardwareQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
setUpdatingHardwareReport(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reportExists = () => hardwareQuery?.data?.hardware_config !== "none";
|
const reportExists = () => hardwareQuery?.data?.hardware_config !== "none";
|
||||||
@@ -197,12 +257,12 @@ const CheckHardware = () => {
|
|||||||
Hardware Report
|
Hardware Report
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
disabled={hardwareQuery.isLoading}
|
disabled={hardwareQuery.isLoading || updatingHardwareReport()}
|
||||||
hierarchy="secondary"
|
hierarchy="secondary"
|
||||||
startIcon="Report"
|
startIcon="Report"
|
||||||
onClick={handleUpdateSummary}
|
onClick={handleUpdateSummary}
|
||||||
class="flex gap-3"
|
class="flex gap-3"
|
||||||
loading={hardwareQuery.isFetching}
|
loading={hardwareQuery.isFetching || updatingHardwareReport()}
|
||||||
>
|
>
|
||||||
Update hardware report
|
Update hardware report
|
||||||
</Button>
|
</Button>
|
||||||
@@ -574,6 +634,10 @@ const InstallSummary = () => {
|
|||||||
}));
|
}));
|
||||||
await runGenerators.result; // Wait for the generators to run
|
await runGenerators.result; // Wait for the generators to run
|
||||||
|
|
||||||
|
const port = store.install.port
|
||||||
|
? parseInt(store.install.port, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const runInstall = client.fetch("run_machine_install", {
|
const runInstall = client.fetch("run_machine_install", {
|
||||||
opts: {
|
opts: {
|
||||||
machine: {
|
machine: {
|
||||||
@@ -585,6 +649,11 @@ const InstallSummary = () => {
|
|||||||
},
|
},
|
||||||
target_host: {
|
target_host: {
|
||||||
address: store.install.targetHost,
|
address: store.install.targetHost,
|
||||||
|
...(port && { port }),
|
||||||
|
ssh_options: {
|
||||||
|
StrictHostKeyChecking: "no",
|
||||||
|
UserKnownHostsFile: "/dev/null",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
set("install", (s) => ({
|
set("install", (s) => ({
|
||||||
@@ -609,6 +678,14 @@ const InstallSummary = () => {
|
|||||||
<Orienter orientation="horizontal">
|
<Orienter orientation="horizontal">
|
||||||
<Display label="Address" value={store.install.targetHost} />
|
<Display label="Address" value={store.install.targetHost} />
|
||||||
</Orienter>
|
</Orienter>
|
||||||
|
{store.install.port && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="horizontal" />
|
||||||
|
<Orienter orientation="horizontal">
|
||||||
|
<Display label="SSH Port" value={store.install.port} />
|
||||||
|
</Orienter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
<Fieldset legend="Disk">
|
<Fieldset legend="Disk">
|
||||||
<Orienter orientation="horizontal">
|
<Orienter orientation="horizontal">
|
||||||
@@ -658,13 +735,13 @@ const InstallProgress = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative flex size-full flex-col items-center justify-center bg-inv-4">
|
<div class="relative flex size-full flex-col items-center justify-end bg-inv-4">
|
||||||
<img
|
<img
|
||||||
src="/logos/usb-stick-min.png"
|
src="/logos/usb-stick-min.png"
|
||||||
alt="usb logo"
|
alt="usb logo"
|
||||||
class="absolute top-2 z-0"
|
class="absolute top-2 z-0"
|
||||||
/>
|
/>
|
||||||
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 fg-inv-1">
|
||||||
<Typography
|
<Typography
|
||||||
hierarchy="title"
|
hierarchy="title"
|
||||||
size="default"
|
size="default"
|
||||||
@@ -796,10 +873,12 @@ export const installSteps = [
|
|||||||
id: "install:progress",
|
id: "install:progress",
|
||||||
content: InstallProgress,
|
content: InstallProgress,
|
||||||
isSplash: true,
|
isSplash: true,
|
||||||
|
class: "max-w-[30rem] h-[18rem]",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "install:done",
|
id: "install:done",
|
||||||
content: InstallDone,
|
content: InstallDone,
|
||||||
isSplash: true,
|
isSplash: true,
|
||||||
|
class: "max-w-[30rem] h-[18rem]",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.content {
|
||||||
|
@apply px-3 flex flex-col gap-5 py-6;
|
||||||
|
border: 1px solid #2e4a4b;
|
||||||
|
background:
|
||||||
|
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
theme(colors.bg.inv.2) 0%,
|
||||||
|
theme(colors.bg.inv.3) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
@apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
border-bottom: 1px solid #2e4a4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
@apply py-3 px-4 flex justify-end w-full;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
border-top: 1px solid #2e4a4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backgroundAlt {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme(colors.bg.inv.3) 0%,
|
||||||
|
theme(colors.bg.inv.4) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user