Compare commits
124 Commits
monitoring
...
serve-json
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac9167f18 | ||
|
|
de0b1b2d70 | ||
|
|
6996a6340a | ||
|
|
3c433da8f5 | ||
|
|
ef2a2bdb67 | ||
|
|
7b61a668e9 | ||
|
|
bdab3e23af | ||
|
|
2b068928a2 | ||
|
|
ec798f89fd | ||
|
|
9efee40477 | ||
|
|
6c6e30ae60 | ||
|
|
b27ff67a14 | ||
|
|
c0ffb17e00 | ||
|
|
e9ccf157b6 | ||
|
|
451f2427fe | ||
|
|
1676cdd9a4 | ||
|
|
109e6473ab | ||
|
|
55acff50d0 | ||
|
|
eee1bd1ae0 | ||
|
|
e46d5870ff | ||
|
|
f6ec32a5d1 | ||
|
|
e336d1b19c | ||
|
|
7399f59652 | ||
|
|
088abe396e | ||
|
|
26b31e24a3 | ||
|
|
099f4c2b8b | ||
|
|
b43605c168 | ||
|
|
899dba5a08 | ||
|
|
d2b94ced5a | ||
|
|
cdf9fa1753 | ||
|
|
d1e7e2993d | ||
|
|
e05d85c759 | ||
|
|
53873411a6 | ||
|
|
39e0ab21bd | ||
|
|
8269d869c3 | ||
|
|
e19d1c8122 | ||
|
|
0cd4ff1b12 | ||
|
|
9aebf02f05 | ||
|
|
ffb7b91da7 | ||
|
|
2d264a8e5e | ||
|
|
abf6893714 | ||
|
|
699c56c721 | ||
|
|
2ce5388a75 | ||
|
|
3e664255d6 | ||
|
|
5b1a9d6848 | ||
|
|
1850abdd0d | ||
|
|
ed503f64da | ||
|
|
4074a184b2 | ||
|
|
6fe2b06f09 | ||
|
|
8fe7cb1b3d | ||
|
|
815c6c9438 | ||
|
|
9ce563aa08 | ||
|
|
c25844dd07 | ||
|
|
a167e70e63 | ||
|
|
dd96fe6b73 | ||
|
|
40d35d37e2 | ||
|
|
071f0f8034 | ||
|
|
81d88fe253 | ||
|
|
ab274ce932 | ||
|
|
ba1e598a76 | ||
|
|
b5d29bd301 | ||
|
|
e174e8e029 | ||
|
|
453d2b4a0a | ||
|
|
aadc8a1d63 | ||
|
|
aaca8f4763 | ||
|
|
0a1a63dfdd | ||
|
|
ee87f20471 | ||
|
|
43febe5f33 | ||
|
|
c63bbabceb | ||
|
|
8f1b270b59 | ||
|
|
da0af8bd53 | ||
|
|
f82d18d649 | ||
|
|
287a303484 | ||
|
|
1213608f30 | ||
|
|
fa1693e8c0 | ||
|
|
ed3ed7cb2a | ||
|
|
b2e88fb3fa | ||
|
|
d6ca50218a | ||
|
|
7d1f0956d6 | ||
|
|
d150c80854 | ||
|
|
2d1828d088 | ||
|
|
f7f897a311 | ||
|
|
683ffbdc76 | ||
|
|
480ad3a5f1 | ||
|
|
16361f03e9 | ||
|
|
3fb8b6587d | ||
|
|
6aee353b43 | ||
|
|
e109361e81 | ||
|
|
3c34f81a44 | ||
|
|
72e7c2e9b9 | ||
|
|
03968d8fbc | ||
|
|
2f27b3941e | ||
|
|
e9dc5b9ba6 | ||
|
|
e4ef885cd5 | ||
|
|
9fe457ebd5 | ||
|
|
4a51aa9316 | ||
|
|
308a10d6e6 | ||
|
|
90f513a08f | ||
|
|
4ddc61d132 | ||
|
|
fc0088e9ea | ||
|
|
71094f7fa1 | ||
|
|
a8516cf9c6 | ||
|
|
a89e2f877a | ||
|
|
ed78e49c47 | ||
|
|
3ef0a7919d | ||
|
|
36812d5f95 | ||
|
|
f5bcdb4ba0 | ||
|
|
b69ad0eca5 | ||
|
|
b221c29694 | ||
|
|
7dc7f09173 | ||
|
|
ec3d224e1d | ||
|
|
00c5312080 | ||
|
|
7811a56d2b | ||
|
|
e9401177b7 | ||
|
|
ef56258e8b | ||
|
|
c4d9b39a17 | ||
|
|
1f59b75c20 | ||
|
|
6b6da7b897 | ||
|
|
4391c19ee9 | ||
|
|
eb993b7060 | ||
|
|
08cb6993a8 | ||
|
|
872f640211 | ||
|
|
c58f7c573d | ||
|
|
7b807a0745 |
@@ -1,9 +0,0 @@
|
||||
name: checks
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
checks-impure:
|
||||
runs-on: nix
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: nix run .#impure-checks
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,7 +39,6 @@ select
|
||||
# Generated files
|
||||
pkgs/clan-app/ui/api/API.json
|
||||
pkgs/clan-app/ui/api/API.ts
|
||||
pkgs/clan-app/ui/api/Inventory.ts
|
||||
pkgs/clan-app/ui/api/modules_schemas.json
|
||||
pkgs/clan-app/ui/api/schema.json
|
||||
pkgs/clan-app/ui/.fonts
|
||||
|
||||
20
CODEOWNERS
20
CODEOWNERS
@@ -0,0 +1,20 @@
|
||||
clanServices/.* @pinpox @kenji
|
||||
|
||||
lib/test/container-test-driver/.* @DavHau @mic92
|
||||
lib/modules/inventory/.* @hsjobeki
|
||||
lib/modules/inventoryClass/.* @hsjobeki
|
||||
|
||||
pkgs/clan-app/ui/.* @hsjobeki @brianmcgee
|
||||
pkgs/clan-app/clan_app/.* @qubasa @hsjobeki
|
||||
|
||||
pkgs/clan-cli/clan_cli/.* @lassulus @mic92 @kenji
|
||||
pkgs/clan-cli/clan_cli/(secrets|vars)/.* @DavHau @lassulus
|
||||
|
||||
pkgs/clan-cli/clan_lib/log_machines/.* @Qubasa
|
||||
pkgs/clan-cli/clan_lib/ssh/.* @Qubasa @Mic92 @lassulus
|
||||
pkgs/clan-cli/clan_lib/tags/.* @hsjobeki
|
||||
pkgs/clan-cli/clan_lib/persist/.* @hsjobeki
|
||||
pkgs/clan-cli/clan_lib/flake/.* @lassulus
|
||||
|
||||
pkgs/clan-cli/api.py @hsjobeki
|
||||
pkgs/clan-cli/openapi.py @hsjobeki
|
||||
@@ -36,7 +36,6 @@ in
|
||||
++ filter pathExists [
|
||||
./devshell/flake-module.nix
|
||||
./flash/flake-module.nix
|
||||
./impure/flake-module.nix
|
||||
./installation/flake-module.nix
|
||||
./update/flake-module.nix
|
||||
./morph/flake-module.nix
|
||||
@@ -139,33 +138,6 @@ in
|
||||
nixosTests
|
||||
// flakeOutputs
|
||||
// {
|
||||
# TODO: Automatically provide this check to downstream users to check their modules
|
||||
clan-modules-json-compatible =
|
||||
let
|
||||
allSchemas = lib.mapAttrs (
|
||||
_n: m:
|
||||
let
|
||||
schema =
|
||||
(self.clanLib.evalService {
|
||||
modules = [ m ];
|
||||
prefix = [
|
||||
"checks"
|
||||
system
|
||||
];
|
||||
}).config.result.api.schema;
|
||||
in
|
||||
schema
|
||||
) self.clan.modules;
|
||||
in
|
||||
pkgs.runCommand "combined-result"
|
||||
{
|
||||
schemaFile = builtins.toFile "schemas.json" (builtins.toJSON allSchemas);
|
||||
}
|
||||
''
|
||||
mkdir -p $out
|
||||
cat $schemaFile > $out/allSchemas.json
|
||||
'';
|
||||
|
||||
clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } ''
|
||||
cp -r ${privateInputs.clan-core-for-checks} $out
|
||||
chmod -R +w $out
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self',
|
||||
...
|
||||
}:
|
||||
{
|
||||
# a script that executes all other checks
|
||||
packages.impure-checks = pkgs.writeShellScriptBin "impure-checks" ''
|
||||
#!${pkgs.bash}/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
unset CLAN_DIR
|
||||
|
||||
export PATH="${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
pkgs.gitMinimal
|
||||
pkgs.nix
|
||||
pkgs.coreutils
|
||||
pkgs.rsync # needed to have rsync installed on the dummy ssh server
|
||||
]
|
||||
++ self'.packages.clan-cli-full.runtimeDependencies
|
||||
)
|
||||
}"
|
||||
ROOT=$(git rev-parse --show-toplevel)
|
||||
cd "$ROOT/pkgs/clan-cli"
|
||||
|
||||
# Set up custom git configuration for tests
|
||||
export GIT_CONFIG_GLOBAL=$(mktemp)
|
||||
git config --file "$GIT_CONFIG_GLOBAL" user.name "Test User"
|
||||
git config --file "$GIT_CONFIG_GLOBAL" user.email "test@example.com"
|
||||
export GIT_CONFIG_SYSTEM=/dev/null
|
||||
|
||||
# this disables dynamic dependency loading in clan-cli
|
||||
export CLAN_NO_DYNAMIC_DEPS=1
|
||||
|
||||
jobs=$(nproc)
|
||||
# Spawning worker in pytest is relatively slow, so we limit the number of jobs to 13
|
||||
# (current number of impure tests)
|
||||
jobs="$((jobs > 13 ? 13 : jobs))"
|
||||
|
||||
nix develop "$ROOT#clan-cli" -c bash -c "TMPDIR=/tmp python -m pytest -n $jobs -m impure ./clan_cli $@"
|
||||
|
||||
# Clean up temporary git config
|
||||
rm -f "$GIT_CONFIG_GLOBAL"
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -241,7 +241,7 @@
|
||||
target.shutdown()
|
||||
except BrokenPipeError:
|
||||
# qemu has already exited
|
||||
pass
|
||||
target.connected = False
|
||||
|
||||
# Create a new machine instance that boots from the installed system
|
||||
installed_machine = create_test_machine(target, "${pkgs.qemu_test}", name="after_install")
|
||||
|
||||
@@ -24,12 +24,5 @@
|
||||
};
|
||||
};
|
||||
|
||||
# roles.prometheus = {
|
||||
# interface = { lib, ... }: { };
|
||||
# };
|
||||
|
||||
imports = [
|
||||
./telegraf.nix
|
||||
./prometheus.nix
|
||||
];
|
||||
imports = [ ./telegraf.nix ];
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
{
|
||||
roles.prometheus.perInstance =
|
||||
{ settings, roles, ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
|
||||
# imports = [
|
||||
# # ./matrix-alertmanager.nix
|
||||
# # ./irc-alertmanager.nix
|
||||
# # ./rules.nix
|
||||
# ];
|
||||
|
||||
services.prometheus = {
|
||||
|
||||
# webExternalUrl = "https://prometheus.thalheim.io";
|
||||
extraFlags = [ "--storage.tsdb.retention.time=30d" ];
|
||||
scrapeConfigs = [
|
||||
{
|
||||
job_name = "telegraf";
|
||||
scrape_interval = "60s";
|
||||
metrics_path = "/metrics";
|
||||
static_configs = [
|
||||
|
||||
(map (host: {
|
||||
|
||||
labels.host = host;
|
||||
# labels.org = "TODO";
|
||||
targets = [ "${host}.clan:9273" ];
|
||||
|
||||
}) lib.attrNames roles.telegraf.machines)
|
||||
|
||||
# {
|
||||
# # labels.host = "rauter.r:9273";
|
||||
# # labels.org = "TODO";
|
||||
# targets = map (host: "${host}.clan:9273") lib.attrNames roles.telegraf.machines;
|
||||
# }
|
||||
];
|
||||
}
|
||||
# {
|
||||
# job_name = "gitea";
|
||||
# scrape_interval = "60s";
|
||||
# metrics_path = "/metrics";
|
||||
#
|
||||
# scheme = "https";
|
||||
# static_configs = [ { targets = [ "git.thalheim.io:443" ]; } ];
|
||||
# }
|
||||
];
|
||||
alertmanagers = [ { static_configs = [ { targets = [ "localhost:9093" ]; } ]; } ];
|
||||
};
|
||||
services.prometheus.alertmanager = {
|
||||
enable = true;
|
||||
# environmentFile = config.sops.secrets.alertmanager.path;
|
||||
# webExternalUrl = "https://alertmanager.thalheim.io";
|
||||
# listenAddress = "[::1]";
|
||||
# configuration = {
|
||||
# global = {
|
||||
# # The smarthost and SMTP sender used for mail notifications.
|
||||
# smtp_smarthost = "mail.thalheim.io:587";
|
||||
# smtp_from = "alertmanager@thalheim.io";
|
||||
# smtp_auth_username = "alertmanager@thalheim.io";
|
||||
# smtp_auth_password = "$SMTP_PASSWORD";
|
||||
# };
|
||||
# route = {
|
||||
# receiver = "default";
|
||||
# routes = [
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# match_re.org = "krebs";
|
||||
# group_wait = "5m";
|
||||
# group_interval = "5m";
|
||||
# repeat_interval = "4h";
|
||||
# receiver = "krebs";
|
||||
# }
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# match_re.org = "nixos-wiki";
|
||||
# group_wait = "5m";
|
||||
# group_interval = "5m";
|
||||
# repeat_interval = "4h";
|
||||
# receiver = "nixos-wiki";
|
||||
# }
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# match_re.org = "numtide";
|
||||
# group_wait = "5m";
|
||||
# group_interval = "5m";
|
||||
# repeat_interval = "4h";
|
||||
# receiver = "numtide";
|
||||
# }
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# match_re.org = "clan-lol";
|
||||
# group_wait = "5m";
|
||||
# group_interval = "5m";
|
||||
# repeat_interval = "4h";
|
||||
# receiver = "clan-lol";
|
||||
# }
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# match_re.org = "dave";
|
||||
# group_wait = "5m";
|
||||
# group_interval = "5m";
|
||||
# repeat_interval = "4h";
|
||||
# receiver = "dave";
|
||||
# }
|
||||
# {
|
||||
# group_by = [ "host" ];
|
||||
# group_wait = "30s";
|
||||
# group_interval = "2m";
|
||||
# repeat_interval = "2h";
|
||||
# receiver = "all";
|
||||
# }
|
||||
# ];
|
||||
# };
|
||||
# receivers = [
|
||||
# {
|
||||
# name = "krebs";
|
||||
# webhook_configs = [
|
||||
# {
|
||||
# url = "http://127.0.0.1:9223/";
|
||||
# max_alerts = 5;
|
||||
# }
|
||||
# ];
|
||||
# }
|
||||
# {
|
||||
# name = "numtide";
|
||||
# webhook_configs = [
|
||||
# # TODO
|
||||
# #{
|
||||
# # send_resolved = true;
|
||||
# # url = "https://chat.ntd.one/plugins/alertmanager/api/webhook?token='xxxxxxxxxxxxxxxxxxx-yyyyyyy'";
|
||||
# #}
|
||||
# ];
|
||||
# }
|
||||
# {
|
||||
# name = "nixos-wiki";
|
||||
# webhook_configs = [
|
||||
# {
|
||||
# url = "http://localhost:9088/alert";
|
||||
# max_alerts = 5;
|
||||
# }
|
||||
# ];
|
||||
# }
|
||||
# {
|
||||
# name = "clan-lol";
|
||||
# webhook_configs = [
|
||||
# # TODO
|
||||
# #{
|
||||
# # url = "http://localhost:4050/services/hooks/YWxlcnRtYW5hZ2VyX3NlcnZpY2U";
|
||||
# # max_alerts = 5;
|
||||
# #}
|
||||
# ];
|
||||
# }
|
||||
# {
|
||||
# name = "dave";
|
||||
# telegram_configs = [
|
||||
# {
|
||||
# chat_id = 42927997;
|
||||
# bot_token = "$TELEGRAM_BOT_TOKEN";
|
||||
# }
|
||||
# ];
|
||||
# }
|
||||
# {
|
||||
# name = "all";
|
||||
# # pushover_configs = [
|
||||
# # {
|
||||
# # user_key = "$PUSHOVER_USER_KEY";
|
||||
# # token = "$PUSHOVER_TOKEN";
|
||||
# # priority = "0";
|
||||
# # }
|
||||
# # ];
|
||||
# }
|
||||
# { name = "default"; }
|
||||
# ];
|
||||
# };
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,22 +4,53 @@
|
||||
{
|
||||
|
||||
nixosModule =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
jsonpath = /tmp/telegraf.json;
|
||||
in
|
||||
{
|
||||
|
||||
networking.firewall.interfaces = lib.mkIf (settings.allowAllInterfaces == false) (
|
||||
builtins.listToAttrs (
|
||||
map (name: {
|
||||
inherit name;
|
||||
value.allowedTCPPorts = [ 9273 ];
|
||||
value.allowedTCPPorts = [ 9273 9990 ];
|
||||
}) settings.interfaces
|
||||
)
|
||||
);
|
||||
|
||||
systemd.services.telegsaf-json.script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath}";
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [ 9273 ];
|
||||
|
||||
clan.core.vars.generators."telegraf-password" = {
|
||||
files.telegraf-password.neededFor = "users";
|
||||
files.telegraf-password.restartUnits = [ "telegraf.service" ];
|
||||
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils
|
||||
pkgs.xkcdpass
|
||||
pkgs.mkpasswd
|
||||
];
|
||||
|
||||
script = ''
|
||||
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
|
||||
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/telegraf-password
|
||||
'';
|
||||
};
|
||||
|
||||
services.telegraf = {
|
||||
enable = true;
|
||||
environmentFiles = [
|
||||
(builtins.toString
|
||||
config.clan.core.vars.generators."telegraf-password".files.telegraf-password.path
|
||||
)
|
||||
];
|
||||
extraConfig = {
|
||||
agent.interval = "60s";
|
||||
inputs = {
|
||||
@@ -46,9 +77,18 @@
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
outputs.file = {
|
||||
files = [ jsonpath ];
|
||||
data_format = "json";
|
||||
json_timestamp_units = "1s";
|
||||
};
|
||||
|
||||
outputs.prometheus_client = {
|
||||
listen = ":9273";
|
||||
metric_version = 2;
|
||||
basic_username = "prometheus";
|
||||
basic_password = "$${BASIC_AUTH_PWD}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
18
devFlake/flake.lock
generated
18
devFlake/flake.lock
generated
@@ -3,10 +3,10 @@
|
||||
"clan-core-for-checks": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1755093452,
|
||||
"narHash": "sha256-NKBss7QtNnOqYVyJmYCgaCvYZK0mpQTQc9fLgE1mGyk=",
|
||||
"lastModified": 1755649112,
|
||||
"narHash": "sha256-Tk/Sjlb7W+5usS4upH0la4aGYs2velkHADnhhn2xO88=",
|
||||
"ref": "main",
|
||||
"rev": "7e97734797f0c6bd3c2d3a51cf54a2a6b371c222",
|
||||
"rev": "bdab3e23af4a9715dfa6346f344f1bd428508672",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://git.clan.lol/clan/clan-core"
|
||||
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1755166611,
|
||||
"narHash": "sha256-sk8pK8kWz4IE4ErAjKE1d8tMChY6VQR32U4yS68FIog=",
|
||||
"lastModified": 1755628699,
|
||||
"narHash": "sha256-IAM29K+Cz9pu90lDDkJpmOpWzQ9Ed0FNkRJcereY+rM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1a341e3c908f4a3105e737bd13af0318dc06fbe3",
|
||||
"rev": "01b1b3809cfa3b0fbca8d75c9cf4b2efdb8182cf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +107,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1754869408,
|
||||
"narHash": "sha256-G1zNuxiCDfqNQVoL9j5v+ZYfUER7AI158ev98/JC8LI=",
|
||||
"lastModified": 1755555503,
|
||||
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"rev": "2f5478267557a0f7a70d953b6c0867a5b4282739",
|
||||
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -40,6 +40,7 @@ writeShellScriptBin "deploy-docs" ''
|
||||
|
||||
rsync \
|
||||
--checksum \
|
||||
--delete \
|
||||
-e "ssh -o StrictHostKeyChecking=no $sshExtraArgs" \
|
||||
-a ${docs}/ \
|
||||
www@clan.lol:/var/www/docs.clan.lol
|
||||
|
||||
@@ -18,27 +18,8 @@
|
||||
inherit (self) clanModules;
|
||||
clan-core = self;
|
||||
inherit pkgs;
|
||||
evalClanModules = self.clanLib.evalClan.evalClanModules;
|
||||
modulesRolesOptions = self.clanLib.evalClan.evalClanModulesWithRoles {
|
||||
allModules = self.clanModules;
|
||||
inherit pkgs;
|
||||
clan-core = self;
|
||||
};
|
||||
};
|
||||
|
||||
# Frontmatter for clanModules
|
||||
clanModulesFrontmatter =
|
||||
let
|
||||
docs = pkgs.nixosOptionsDoc {
|
||||
options = self.clanLib.modules.frontmatterOptions;
|
||||
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
|
||||
};
|
||||
in
|
||||
docs.optionsJSON;
|
||||
|
||||
# Options available when imported via ` inventory.${moduleName}....${rolesName} `
|
||||
clanModulesViaRoles = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaRoles);
|
||||
|
||||
# clan service options
|
||||
clanModulesViaService = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaService);
|
||||
|
||||
@@ -88,12 +69,10 @@
|
||||
}
|
||||
}
|
||||
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
|
||||
|
||||
# A file that contains the links to all clanModule docs
|
||||
export CLAN_MODULES_VIA_ROLES=${clanModulesViaRoles}
|
||||
export CLAN_MODULES_VIA_SERVICE=${clanModulesViaService}
|
||||
export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json
|
||||
# Frontmatter format for clanModules
|
||||
export CLAN_MODULES_FRONTMATTER_DOCS=${clanModulesFrontmatter}/share/doc/nixos/options.json
|
||||
|
||||
export BUILD_CLAN_PATH=${buildClanOptions}/share/doc/nixos/options.json
|
||||
|
||||
@@ -107,7 +86,6 @@
|
||||
legacyPackages = {
|
||||
inherit
|
||||
jsonDocs
|
||||
clanModulesViaRoles
|
||||
clanModulesViaService
|
||||
;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
modulesRolesOptions,
|
||||
nixosOptionsDoc,
|
||||
evalClanModules,
|
||||
lib,
|
||||
pkgs,
|
||||
clan-core,
|
||||
@@ -10,21 +8,36 @@
|
||||
let
|
||||
inherit (clan-core.clanLib.docs) stripStorePathsFromDeclarations;
|
||||
transformOptions = stripStorePathsFromDeclarations;
|
||||
|
||||
nixosConfigurationWithClan =
|
||||
let
|
||||
evaled = lib.evalModules {
|
||||
class = "nixos";
|
||||
modules = [
|
||||
# Basemodule
|
||||
(
|
||||
{ config, ... }:
|
||||
{
|
||||
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
|
||||
nixpkgs.pkgs = pkgs;
|
||||
clan.core.name = "dummy";
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
# Set this to work around a bug where `clan.core.settings.machine.name`
|
||||
# is forced due to `networking.interfaces` being forced
|
||||
# somewhere in the nixpkgs options
|
||||
facter.detected.dhcp.enable = lib.mkForce false;
|
||||
}
|
||||
)
|
||||
{
|
||||
clan.core.settings.directory = clan-core;
|
||||
}
|
||||
clan-core.nixosModules.clanCore
|
||||
];
|
||||
};
|
||||
in
|
||||
evaled;
|
||||
in
|
||||
{
|
||||
|
||||
clanModulesViaRoles = lib.mapAttrs (
|
||||
_moduleName: rolesOptions:
|
||||
lib.mapAttrs (
|
||||
_roleName: options:
|
||||
(nixosOptionsDoc {
|
||||
inherit options;
|
||||
warningsAreErrors = true;
|
||||
inherit transformOptions;
|
||||
}).optionsJSON
|
||||
) rolesOptions
|
||||
) modulesRolesOptions;
|
||||
|
||||
# Test with:
|
||||
# nix build .\#legacyPackages.x86_64-linux.clanModulesViaService
|
||||
clanModulesViaService = lib.mapAttrs (
|
||||
@@ -38,7 +51,6 @@ in
|
||||
{
|
||||
roles = lib.mapAttrs (
|
||||
_roleName: role:
|
||||
|
||||
(nixosOptionsDoc {
|
||||
transformOptions =
|
||||
opt:
|
||||
@@ -54,20 +66,13 @@ in
|
||||
warningsAreErrors = true;
|
||||
}).optionsJSON
|
||||
) evaluatedService.config.roles;
|
||||
|
||||
manifest = evaluatedService.config.manifest;
|
||||
|
||||
}
|
||||
) clan-core.clan.modules;
|
||||
|
||||
clanCore =
|
||||
(nixosOptionsDoc {
|
||||
options =
|
||||
((evalClanModules {
|
||||
modules = [ ];
|
||||
inherit pkgs clan-core;
|
||||
}).options
|
||||
).clan.core or { };
|
||||
options = nixosConfigurationWithClan.options.clan.core;
|
||||
warningsAreErrors = true;
|
||||
inherit transformOptions;
|
||||
}).optionsJSON;
|
||||
|
||||
@@ -33,22 +33,13 @@ from clan_lib.errors import ClanError
|
||||
from clan_lib.services.modules import (
|
||||
CategoryInfo,
|
||||
Frontmatter,
|
||||
extract_frontmatter,
|
||||
get_roles,
|
||||
)
|
||||
|
||||
# Get environment variables
|
||||
CLAN_CORE_PATH = Path(os.environ["CLAN_CORE_PATH"])
|
||||
CLAN_CORE_DOCS = Path(os.environ["CLAN_CORE_DOCS"])
|
||||
CLAN_MODULES_FRONTMATTER_DOCS = os.environ.get("CLAN_MODULES_FRONTMATTER_DOCS")
|
||||
BUILD_CLAN_PATH = os.environ.get("BUILD_CLAN_PATH")
|
||||
|
||||
## Clan modules ##
|
||||
# Some modules can be imported via nix natively
|
||||
CLAN_MODULES_VIA_NIX = os.environ.get("CLAN_MODULES_VIA_NIX")
|
||||
# Some modules can be imported via inventory
|
||||
CLAN_MODULES_VIA_ROLES = os.environ.get("CLAN_MODULES_VIA_ROLES")
|
||||
|
||||
# Options how to author clan.modules
|
||||
# perInstance, perMachine, ...
|
||||
CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
|
||||
@@ -190,23 +181,6 @@ def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
|
||||
return f"# {module_name}{indicator}\n\n"
|
||||
|
||||
|
||||
def module_nix_usage(module_name: str) -> str:
|
||||
return f"""## Usage via Nix
|
||||
|
||||
**This module can be also imported directly in your nixos configuration. Although it is recommended to use the [inventory](../../concepts/inventory.md) interface if available.**
|
||||
|
||||
Some modules are considered 'low-level' or 'expert modules' and are not available via the inventory interface.
|
||||
|
||||
```nix
|
||||
{{config, lib, inputs, ...}}: {{
|
||||
imports = [ inputs.clan-core.clanModules.{module_name} ];
|
||||
# ...
|
||||
}}
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
||||
clan_core_descr = """
|
||||
`clan.core` is always present in a clan machine
|
||||
|
||||
@@ -223,68 +197,6 @@ The following options are available for this module.
|
||||
"""
|
||||
|
||||
|
||||
def produce_clan_modules_frontmatter_docs() -> None:
|
||||
if not CLAN_MODULES_FRONTMATTER_DOCS:
|
||||
msg = f"Environment variables are not set correctly: $CLAN_CORE_DOCS={CLAN_CORE_DOCS}"
|
||||
raise ClanError(msg)
|
||||
|
||||
if not OUT:
|
||||
msg = f"Environment variables are not set correctly: $out={OUT}"
|
||||
raise ClanError(msg)
|
||||
|
||||
with Path(CLAN_MODULES_FRONTMATTER_DOCS).open() as f:
|
||||
options: dict[str, dict[str, Any]] = json.load(f)
|
||||
|
||||
# header
|
||||
output = """# Frontmatter
|
||||
|
||||
Every clan module has a `frontmatter` section within its readme. It provides
|
||||
machine readable metadata about the module.
|
||||
|
||||
!!! example
|
||||
|
||||
The used format is `TOML`
|
||||
|
||||
The content is separated by `---` and the frontmatter must be placed at the very top of the `README.md` file.
|
||||
|
||||
```toml
|
||||
---
|
||||
description = "A description of the module"
|
||||
categories = ["category1", "category2"]
|
||||
|
||||
[constraints]
|
||||
roles.client.max = 10
|
||||
roles.server.min = 1
|
||||
---
|
||||
# Readme content
|
||||
...
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
output += """## Overview
|
||||
|
||||
This provides an overview of the available attributes of the `frontmatter`
|
||||
within the `README.md` of a clan module.
|
||||
|
||||
"""
|
||||
# for option_name, info in options.items():
|
||||
# if option_name == "_module.args":
|
||||
# continue
|
||||
# output += render_option(option_name, info)
|
||||
root = options_to_tree(options, debug=True)
|
||||
for option in root.suboptions:
|
||||
output += options_docs_from_tree(option, init_level=2)
|
||||
|
||||
outfile = Path(OUT) / "clanModules/frontmatter/index.md"
|
||||
outfile.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
with outfile.open("w") as of:
|
||||
of.write(output)
|
||||
|
||||
|
||||
def produce_clan_core_docs() -> None:
|
||||
if not CLAN_CORE_DOCS:
|
||||
msg = f"Environment variables are not set correctly: $CLAN_CORE_DOCS={CLAN_CORE_DOCS}"
|
||||
@@ -505,154 +417,6 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
|
||||
of.write(output)
|
||||
|
||||
|
||||
def produce_clan_modules_docs() -> None:
|
||||
if not CLAN_MODULES_VIA_NIX:
|
||||
msg = f"Environment variables are not set correctly: $CLAN_MODULES_VIA_NIX={CLAN_MODULES_VIA_NIX}"
|
||||
raise ClanError(msg)
|
||||
|
||||
if not CLAN_MODULES_VIA_ROLES:
|
||||
msg = f"Environment variables are not set correctly: $CLAN_MODULES_VIA_ROLES={CLAN_MODULES_VIA_ROLES}"
|
||||
raise ClanError(msg)
|
||||
|
||||
if not CLAN_CORE_PATH:
|
||||
msg = f"Environment variables are not set correctly: $CLAN_CORE_PATH={CLAN_CORE_PATH}"
|
||||
raise ClanError(msg)
|
||||
|
||||
if not OUT:
|
||||
msg = f"Environment variables are not set correctly: $out={OUT}"
|
||||
raise ClanError(msg)
|
||||
|
||||
modules_index = "# Modules Overview\n\n"
|
||||
modules_index += clan_modules_descr
|
||||
modules_index += "## Overview\n\n"
|
||||
modules_index += '<div class="grid cards" markdown>\n\n'
|
||||
|
||||
with Path(CLAN_MODULES_VIA_ROLES).open() as f2:
|
||||
role_links: dict[str, dict[str, str]] = json.load(f2)
|
||||
|
||||
with Path(CLAN_MODULES_VIA_NIX).open() as f:
|
||||
links: dict[str, str] = json.load(f)
|
||||
|
||||
for module_name, options_file in links.items():
|
||||
print(f"Rendering ClanModule: {module_name}")
|
||||
readme_file = CLAN_CORE_PATH / "clanModules" / module_name / "README.md"
|
||||
with readme_file.open() as f:
|
||||
readme = f.read()
|
||||
frontmatter: Frontmatter
|
||||
frontmatter, readme_content = extract_frontmatter(readme, str(readme_file))
|
||||
|
||||
# skip if experimental feature enabled
|
||||
if "experimental" in frontmatter.features:
|
||||
print(f"Skipping {module_name}: Experimental feature")
|
||||
continue
|
||||
|
||||
modules_index += build_option_card(module_name, frontmatter)
|
||||
|
||||
##### Print module documentation #####
|
||||
|
||||
# 1. Header
|
||||
output = module_header(module_name, "inventory" in frontmatter.features)
|
||||
|
||||
# 2. Description from README.md
|
||||
if frontmatter.description:
|
||||
output += f"*{frontmatter.description}*\n\n"
|
||||
|
||||
# 2. Deprecation note if the module is deprecated
|
||||
if "deprecated" in frontmatter.features:
|
||||
output += f"""
|
||||
!!! Warning "Deprecated"
|
||||
The `{module_name}` module is deprecated.*
|
||||
|
||||
Use 'clanServices/{module_name}' or a similar successor instead
|
||||
"""
|
||||
else:
|
||||
output += f"""
|
||||
!!! Warning "Will be deprecated"
|
||||
The `{module_name}` module might eventually be migrated to 'clanServices'*
|
||||
|
||||
See: [clanServices](../../guides/clanServices.md)
|
||||
"""
|
||||
|
||||
# 3. Categories from README.md
|
||||
output += "## Categories\n\n"
|
||||
output += render_categories(frontmatter.categories, frontmatter.categories_info)
|
||||
output += "\n---\n\n"
|
||||
|
||||
# 3. README.md content
|
||||
output += f"{readme_content}\n"
|
||||
|
||||
# 4. Usage
|
||||
##### Print usage via Inventory #####
|
||||
|
||||
# get_roles(str) -> list[str] | None
|
||||
# if not isinstance(options_file, str):
|
||||
roles = get_roles(CLAN_CORE_PATH / "clanModules" / module_name)
|
||||
if roles:
|
||||
# Render inventory usage
|
||||
output += """## Usage via Inventory\n\n"""
|
||||
output += render_roles(roles, module_name)
|
||||
for role in roles:
|
||||
role_options_file = role_links[module_name][role]
|
||||
# Abort if the options file is not found
|
||||
if not isinstance(role_options_file, str):
|
||||
print(
|
||||
f"Error: module: {module_name} in role: {role} - options file not found, Got {role_options_file}"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
no_options = f"""### Options of `{role}` role
|
||||
|
||||
**The `{module_name}` `{role}` doesnt offer / require any options to be set.**
|
||||
"""
|
||||
|
||||
heading = f"""### Options of `{role}` role
|
||||
|
||||
The following options are available when using the `{role}` role.
|
||||
"""
|
||||
output += print_options(
|
||||
role_options_file,
|
||||
heading,
|
||||
no_options,
|
||||
replace_prefix=f"clan.{module_name}",
|
||||
)
|
||||
else:
|
||||
# No roles means no inventory usage
|
||||
output += """## Usage via Inventory
|
||||
|
||||
**This module cannot be used via the inventory interface.**
|
||||
"""
|
||||
|
||||
##### Print usage via Nix / nixos #####
|
||||
if not isinstance(options_file, str):
|
||||
print(
|
||||
f"Skipping {module_name}: Cannot be used via import clanModules.{module_name}"
|
||||
)
|
||||
output += """## Usage via Nix
|
||||
|
||||
**This module cannot be imported directly in your nixos configuration.**
|
||||
|
||||
"""
|
||||
else:
|
||||
output += module_nix_usage(module_name)
|
||||
no_options = "** This module doesnt require any options to be set.**"
|
||||
output += print_options(options_file, options_head, no_options)
|
||||
|
||||
outfile = Path(OUT) / f"clanModules/{module_name}.md"
|
||||
outfile.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
with outfile.open("w") as of:
|
||||
of.write(output)
|
||||
|
||||
modules_index += "</div>"
|
||||
modules_index += "\n"
|
||||
modules_outfile = Path(OUT) / "clanModules/index.md"
|
||||
|
||||
with modules_outfile.open("w") as of:
|
||||
of.write(modules_index)
|
||||
|
||||
|
||||
def build_option_card(module_name: str, frontmatter: Frontmatter) -> str:
|
||||
"""
|
||||
Build the overview index card for each reference target option.
|
||||
@@ -863,8 +627,4 @@ if __name__ == "__main__": #
|
||||
produce_clan_core_docs()
|
||||
|
||||
produce_clan_service_author_docs()
|
||||
|
||||
# produce_clan_modules_docs()
|
||||
produce_clan_service_docs()
|
||||
|
||||
# produce_clan_modules_frontmatter_docs()
|
||||
|
||||
@@ -90,13 +90,10 @@ export CLAN_DEBUG_COMMANDS=1
|
||||
These options help you pinpoint the source and context of print messages and debug logs during development.
|
||||
|
||||
|
||||
|
||||
## Analyzing Performance
|
||||
|
||||
To understand what's causing slow performance, set the environment variable `export CLAN_CLI_PERF=1`. When you complete a clan command, you'll see a summary of various performance metrics, helping you identify what's taking up time.
|
||||
|
||||
|
||||
|
||||
## See all possible packages and tests
|
||||
|
||||
To quickly show all possible packages and tests execute:
|
||||
@@ -155,28 +152,16 @@ To test the CLI locally in a development environment and set breakpoints for deb
|
||||
|
||||
## Test Locally in a Nix Sandbox
|
||||
|
||||
To run tests in a Nix sandbox, you have two options depending on whether your test functions have been marked as impure or not:
|
||||
|
||||
### Running Tests Marked as Impure
|
||||
|
||||
If your test functions need to execute `nix build` and have been marked as impure because you can't execute `nix build` inside a Nix sandbox, use the following command:
|
||||
To run tests in a Nix sandbox:
|
||||
|
||||
```bash
|
||||
nix run .#impure-checks -L
|
||||
nix build .#checks.x86_64-linux.clan-pytest-with-core
|
||||
```
|
||||
|
||||
This command will run the impure test functions.
|
||||
|
||||
### Running Pure Tests
|
||||
|
||||
For test functions that have not been marked as impure and don't require executing `nix build`, you can use the following command:
|
||||
|
||||
```bash
|
||||
nix build .#checks.x86_64-linux.clan-pytest --rebuild
|
||||
nix build .#checks.x86_64-linux.clan-pytest-without-core
|
||||
```
|
||||
|
||||
This command will run all pure test functions.
|
||||
|
||||
### Inspecting the Nix Sandbox
|
||||
|
||||
If you need to inspect the Nix sandbox while running tests, follow these steps:
|
||||
|
||||
@@ -1,110 +1,129 @@
|
||||
# :material-clock-fast: Getting Started
|
||||
|
||||
Ready to create your own Clan and manage a fleet of machines? Follow these simple steps to get started.
|
||||
Ready to manage your fleet of machines?
|
||||
|
||||
This guide walks your through setting up your own declarative infrastructure using clan, git and flakes. By the end of this, you will have one or more machines integrated and installed. You can then import your existing NixOS configuration into this setup if you wish.
|
||||
We will create a declarative infrastructure using **clan**, **git**, and **nix flakes**.
|
||||
|
||||
The following steps are meant to be executed on the machine on which to administer the infrastructure.
|
||||
|
||||
In order to get started you should have at least one machine with either physical or ssh access available as an installation target. Your local machine can also be used as an installation target if it is already running NixOS.
|
||||
You'll finish with a centrally managed fleet, ready to import your existing NixOS configuration.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
=== "**Linux**"
|
||||
Make sure you have the following:
|
||||
|
||||
Clan requires Nix to be installed on your system. Run the following command to install Nix:
|
||||
* 💻 **Administration Machine**: Run the setup commands from this machine.
|
||||
* 🛠️ **Nix**: The Nix package manager, installed on your administration machine.
|
||||
|
||||
??? info "**How to install Nix (Linux / MacOS / NixOS)**"
|
||||
|
||||
**On Linux or macOS:**
|
||||
|
||||
1. Run the recommended installer:
|
||||
```shellSession
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L [https://install.determinate.systems/nix](https://install.determinate.systems/nix) | sh -s -- install
|
||||
```
|
||||
|
||||
2. After installation, ensure flakes are enabled by adding this line to `~/.config/nix/nix.conf`:
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
**On NixOS:**
|
||||
|
||||
Nix is already installed. You only need to enable flakes for your user in your `configuration.nix`:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
}
|
||||
```
|
||||
Then, run `nixos-rebuild switch` to apply the changes.
|
||||
|
||||
* 🎯 **Target Machine(s)**: A remote machine with SSH, or your local machine (if NixOS).
|
||||
|
||||
## Create a New Clan
|
||||
|
||||
1. Navigate to your desired directory:
|
||||
|
||||
```shellSession
|
||||
cd <your-directory>
|
||||
```
|
||||
|
||||
2. Create a new clan flake:
|
||||
|
||||
**Note:** This creates a new directory in your current location
|
||||
|
||||
```shellSession
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
|
||||
nix run https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli --refresh -- flakes create
|
||||
```
|
||||
|
||||
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
|
||||
3. Enter a **name** in the prompt:
|
||||
|
||||
=== "**NixOS**"
|
||||
|
||||
If you run NixOS the `nix` binary is already installed.
|
||||
|
||||
You will also need to enable the `nix-command` and `flakes` experimental features in your `configuration.nix`:
|
||||
|
||||
```nix
|
||||
{ nix.settings.experimental-features = [ "nix-command" "flakes" ]; }
|
||||
```terminalSession
|
||||
Enter a name for the new clan: my-clan
|
||||
```
|
||||
|
||||
=== "**macOS**"
|
||||
## Project Structure
|
||||
|
||||
Clan requires Nix to be installed on your system. Run the following command to install Nix:
|
||||
Your new directory, `my-clan`, should contain the following structure:
|
||||
|
||||
```shellSession
|
||||
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
|
||||
```
|
||||
```
|
||||
my-clan/
|
||||
├── clan.nix
|
||||
├── flake.lock
|
||||
├── flake.nix
|
||||
├── modules/
|
||||
└── sops/
|
||||
```
|
||||
|
||||
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
|
||||
!!! note "Templates"
|
||||
This is the structure for the `default` template.
|
||||
|
||||
## Create a new clan
|
||||
Use `clan templates list` and `clan templates --help` for available templates & more. Keep in mind that the exact files may change as templates evolve.
|
||||
|
||||
Initialize a new clan flake
|
||||
|
||||
## Activate the Environment
|
||||
|
||||
To get started, `cd` into your new project directory.
|
||||
|
||||
```shellSession
|
||||
nix run https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli --refresh -- flakes create
|
||||
cd my-clan
|
||||
```
|
||||
|
||||
This should prompt for a *name*:
|
||||
|
||||
```terminalSession
|
||||
Enter a name for the new clan: my-clan
|
||||
```
|
||||
|
||||
Enter a *name*, confirm with *enter*. A directory with that name will be created and initialized.
|
||||
|
||||
!!! Note
|
||||
This command uses the `default` template
|
||||
|
||||
See `clan templates list` and the `--help` reference for how to use other templates.
|
||||
|
||||
## Explore the Project Structure
|
||||
|
||||
Take a look at all project files:
|
||||
For example, you might see something like:
|
||||
|
||||
```{ .console .no-copy }
|
||||
$ cd my-clan
|
||||
$ ls
|
||||
clan.nix flake.lock flake.nix modules sops
|
||||
```
|
||||
|
||||
|
||||
|
||||
Don’t worry if your output looks different — Clan templates evolve over time.
|
||||
|
||||
To interact with your newly created clan the you need to load the `clan` cli-package it into your environment by running:
|
||||
Now, activate the environment using one of the following methods.
|
||||
|
||||
=== "Automatic (direnv, recommended)"
|
||||
- prerequisite: [install nix-direnv](https://github.com/nix-community/nix-direnv)
|
||||
**Prerequisite**: You must have [nix-direnv](https://github.com/nix-community/nix-direnv) installed.
|
||||
|
||||
Run `direnv allow` to automatically load the environment whenever you enter this directory.
|
||||
```shellSession
|
||||
direnv allow
|
||||
```
|
||||
|
||||
=== "Manual (nix develop)"
|
||||
Run nix develop to load the environment for your current shell session.
|
||||
|
||||
```shellSession
|
||||
nix develop
|
||||
```
|
||||
|
||||
verify that you can run `clan` commands:
|
||||
## Verify the Setup
|
||||
|
||||
Once your environment is active, verify that the clan command is available by running:
|
||||
|
||||
```shellSession
|
||||
clan show
|
||||
```
|
||||
|
||||
You should see something like this:
|
||||
You should see the default metadata for your new clan:
|
||||
|
||||
```shellSession
|
||||
Name: __CHANGE_ME__
|
||||
Description: None
|
||||
```
|
||||
|
||||
To change the name of your clan edit `meta.name` in the `clan.nix` or `flake.nix` file
|
||||
This confirms your setup is working correctly.
|
||||
|
||||
You can now change the default name by editing the `meta.name` field in your `clan.nix` file.
|
||||
|
||||
```{.nix title="clan.nix" hl_lines="3"}
|
||||
{
|
||||
|
||||
24
flake.lock
generated
24
flake.lock
generated
@@ -31,11 +31,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1754971456,
|
||||
"narHash": "sha256-p04ZnIBGzerSyiY2dNGmookCldhldWAu03y0s3P8CB0=",
|
||||
"lastModified": 1755519972,
|
||||
"narHash": "sha256-bU4nqi3IpsUZJeyS8Jk85ytlX61i4b0KCxXX9YcOgVc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "8246829f2e675a46919718f9a64b71afe3bfb22d",
|
||||
"rev": "4073ff2f481f9ef3501678ff479ed81402caae6d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -71,11 +71,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1751313918,
|
||||
"narHash": "sha256-HsJM3XLa43WpG+665aGEh8iS8AfEwOIQWk3Mke3e7nk=",
|
||||
"lastModified": 1755275010,
|
||||
"narHash": "sha256-lEApCoWUEWh0Ifc3k1JdVjpMtFFXeL2gG1qvBnoRc2I=",
|
||||
"owner": "nix-darwin",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "e04a388232d9a6ba56967ce5b53a8a6f713cdfcf",
|
||||
"rev": "7220b01d679e93ede8d7b25d6f392855b81dd475",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -99,11 +99,11 @@
|
||||
},
|
||||
"nixos-facter-modules": {
|
||||
"locked": {
|
||||
"lastModified": 1750412875,
|
||||
"narHash": "sha256-uP9Xxw5XcFwjX9lNoYRpybOnIIe1BHfZu5vJnnPg3Jc=",
|
||||
"lastModified": 1755504238,
|
||||
"narHash": "sha256-mw7q5DPdmz/1au8mY0u1DztRgVyJToGJfJszxjKSNes=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-facter-modules",
|
||||
"rev": "14df13c84552a7d1f33c1cd18336128fbc43f920",
|
||||
"rev": "354ed498c9628f32383c3bf5b6668a17cdd72a28",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -115,10 +115,10 @@
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 315532800,
|
||||
"narHash": "sha256-2ILJtWugqmMyZnaWnHh+5yyw8RZWbKu9rVdeWmrBVhY=",
|
||||
"rev": "a595dde4d0d31606e19dcec73db02279db59d201",
|
||||
"narHash": "sha256-h8Sx4S+/0FpodZji6W9lHzwY5BcuUG85Aj3GfhvGC2o=",
|
||||
"rev": "a650b5d0de99158323597f048667c4d914243224",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre844295.a595dde4d0d3/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre845298.a650b5d0de99/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
clan = {
|
||||
meta.name = "clan-core";
|
||||
inventory = {
|
||||
services = { };
|
||||
machines = {
|
||||
"test-darwin-machine" = {
|
||||
machineClass = "darwin";
|
||||
@@ -97,6 +96,7 @@
|
||||
./nixosModules/flake-module.nix
|
||||
./pkgs/flake-module.nix
|
||||
./templates/flake-module.nix
|
||||
./pkgs/clan-cli/clan_cli/tests/flake-module.nix
|
||||
]
|
||||
++ [
|
||||
(if pathExists ./flakeModules/clan.nix then import ./flakeModules/clan.nix inputs.self else { })
|
||||
|
||||
@@ -33,7 +33,6 @@ lib.fix (
|
||||
evalService = clanLib.callLib ./modules/inventory/distributed-service/evalService.nix { };
|
||||
# ------------------------------------
|
||||
# ClanLib functions
|
||||
evalClan = clanLib.callLib ./modules/inventory/eval-clan-modules { };
|
||||
inventory = clanLib.callLib ./modules/inventory { };
|
||||
modules = clanLib.callLib ./modules/inventory/frontmatter { };
|
||||
test = clanLib.callLib ./test { };
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
clanLib,
|
||||
}:
|
||||
let
|
||||
baseModule =
|
||||
{ pkgs }:
|
||||
# Module
|
||||
{ config, ... }:
|
||||
{
|
||||
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
|
||||
nixpkgs.pkgs = pkgs;
|
||||
clan.core.name = "dummy";
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
# Set this to work around a bug where `clan.core.settings.machine.name`
|
||||
# is forced due to `networking.interfaces` being forced
|
||||
# somewhere in the nixpkgs options
|
||||
facter.detected.dhcp.enable = lib.mkForce false;
|
||||
};
|
||||
|
||||
# This function takes a list of module names and evaluates them
|
||||
# [ module ] -> { config, options, ... }
|
||||
evalClanModulesLegacy =
|
||||
{
|
||||
modules,
|
||||
pkgs,
|
||||
clan-core,
|
||||
}:
|
||||
let
|
||||
evaled = lib.evalModules {
|
||||
class = "nixos";
|
||||
modules = [
|
||||
(baseModule { inherit pkgs; })
|
||||
{
|
||||
clan.core.settings.directory = clan-core;
|
||||
}
|
||||
clan-core.nixosModules.clanCore
|
||||
]
|
||||
++ modules;
|
||||
};
|
||||
in
|
||||
# lib.warn ''
|
||||
# doesn't respect role specific interfaces.
|
||||
|
||||
# The following {module}/default.nix file trying to be imported.
|
||||
|
||||
# Modules: ${builtins.toJSON modulenames}
|
||||
|
||||
# This might result in incomplete or incorrect interfaces.
|
||||
|
||||
# FIX: Use evalClanModuleWithRole instead.
|
||||
# ''
|
||||
evaled;
|
||||
|
||||
/*
|
||||
This function takes a list of module names and evaluates them
|
||||
Returns a set of interfaces as described below:
|
||||
|
||||
Fn :: { ${moduleName} = Module; } -> {
|
||||
${moduleName} :: {
|
||||
${roleName}: JSONSchema
|
||||
}
|
||||
}
|
||||
*/
|
||||
evalClanModulesWithRoles =
|
||||
{
|
||||
allModules,
|
||||
clan-core,
|
||||
pkgs,
|
||||
}:
|
||||
let
|
||||
res = builtins.mapAttrs (
|
||||
moduleName: module:
|
||||
let
|
||||
frontmatter = clanLib.modules.getFrontmatter allModules.${moduleName} moduleName;
|
||||
roles =
|
||||
if builtins.elem "inventory" frontmatter.features or [ ] then
|
||||
assert lib.isPath module;
|
||||
clan-core.clanLib.modules.getRoles "Documentation: inventory.modules" allModules moduleName
|
||||
else
|
||||
[ ];
|
||||
in
|
||||
lib.listToAttrs (
|
||||
lib.map (role: {
|
||||
name = role;
|
||||
value =
|
||||
(lib.evalModules {
|
||||
class = "nixos";
|
||||
modules = [
|
||||
(baseModule { inherit pkgs; })
|
||||
clan-core.nixosModules.clanCore
|
||||
{
|
||||
clan.core.settings.directory = clan-core;
|
||||
}
|
||||
# Role interface
|
||||
(module + "/roles/${role}.nix")
|
||||
];
|
||||
}).options.clan.${moduleName} or { };
|
||||
}) roles
|
||||
)
|
||||
) allModules;
|
||||
in
|
||||
res;
|
||||
in
|
||||
{
|
||||
evalClanModules = evalClanModulesLegacy;
|
||||
inherit evalClanModulesWithRoles;
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
{
|
||||
self,
|
||||
inputs,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./distributed-service/flake-module.nix
|
||||
@@ -15,16 +11,13 @@ in
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
system,
|
||||
self',
|
||||
...
|
||||
}:
|
||||
{
|
||||
devShells.inventory-schema = pkgs.mkShell {
|
||||
name = "clan-inventory-schema";
|
||||
inputsFrom = with config.checks; [
|
||||
eval-lib-inventory
|
||||
inputsFrom = [
|
||||
self'.devShells.default
|
||||
];
|
||||
};
|
||||
@@ -51,41 +44,5 @@ in
|
||||
warningsAreErrors = true;
|
||||
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
|
||||
}).optionsJSON;
|
||||
|
||||
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
|
||||
legacyPackages.evalTests-inventory = import ./tests {
|
||||
inherit lib;
|
||||
clan-core = self;
|
||||
inherit (self) clanLib;
|
||||
inherit (self.inputs) nix-darwin;
|
||||
};
|
||||
|
||||
checks = {
|
||||
eval-lib-inventory = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
export NIX_ABORT_ON_WARN=1
|
||||
nix-unit --eval-store "$HOME" \
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${
|
||||
lib.fileset.toSource {
|
||||
root = ../../..;
|
||||
fileset = lib.fileset.unions [
|
||||
../../../flake.nix
|
||||
../../../flake.lock
|
||||
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
|
||||
../../../flakeModules
|
||||
../../../lib
|
||||
../../../nixosModules/clanCore
|
||||
../../../machines
|
||||
../../../inventory.json
|
||||
];
|
||||
}
|
||||
}#legacyPackages.${system}.evalTests-inventory
|
||||
|
||||
touch $out
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,51 +3,6 @@ let
|
||||
# Trim the .nix extension from a filename
|
||||
trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name;
|
||||
|
||||
jsonWithoutHeader = clanLib.jsonschema {
|
||||
includeDefaults = true;
|
||||
header = { };
|
||||
};
|
||||
|
||||
getModulesSchema =
|
||||
{
|
||||
modules,
|
||||
clan-core,
|
||||
pkgs,
|
||||
}:
|
||||
lib.mapAttrs
|
||||
(
|
||||
_moduleName: rolesOptions:
|
||||
lib.mapAttrs (_roleName: options: jsonWithoutHeader.parseOptions options { }) rolesOptions
|
||||
)
|
||||
(
|
||||
clanLib.evalClan.evalClanModulesWithRoles {
|
||||
allModules = modules;
|
||||
inherit pkgs clan-core;
|
||||
}
|
||||
);
|
||||
|
||||
evalFrontmatter =
|
||||
{
|
||||
moduleName,
|
||||
instanceName,
|
||||
resolvedRoles,
|
||||
allModules,
|
||||
}:
|
||||
lib.evalModules {
|
||||
modules = [
|
||||
(getFrontmatter allModules.${moduleName} moduleName)
|
||||
./interface.nix
|
||||
{
|
||||
constraints.imports = [
|
||||
(lib.modules.importApply ../constraints {
|
||||
inherit moduleName resolvedRoles instanceName;
|
||||
allRoles = getRoles "inventory.modules" allModules moduleName;
|
||||
})
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# For Documentation purposes only
|
||||
frontmatterOptions =
|
||||
(lib.evalModules {
|
||||
@@ -119,17 +74,12 @@ let
|
||||
builtins.readDir (checkedPath)
|
||||
)
|
||||
);
|
||||
|
||||
checkConstraints = args: (evalFrontmatter args).config.constraints.assertions;
|
||||
getFrontmatter = _modulepath: _modulename: "clanModules are removed!";
|
||||
in
|
||||
{
|
||||
inherit
|
||||
frontmatterOptions
|
||||
getModulesSchema
|
||||
getFrontmatter
|
||||
|
||||
checkConstraints
|
||||
getRoles
|
||||
;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
{
|
||||
self,
|
||||
self',
|
||||
lib,
|
||||
pkgs,
|
||||
flakeOptions,
|
||||
...
|
||||
}:
|
||||
let
|
||||
|
||||
modulesSchema = self.clanLib.modules.getModulesSchema {
|
||||
modules = self.clanModules;
|
||||
inherit pkgs;
|
||||
clan-core = self;
|
||||
};
|
||||
|
||||
jsonLib = self.clanLib.jsonschema { inherit includeDefaults; };
|
||||
includeDefaults = true;
|
||||
|
||||
frontMatterSchema = jsonLib.parseOptions self.clanLib.modules.frontmatterOptions { };
|
||||
|
||||
inventorySchema = jsonLib.parseModule ({
|
||||
imports = [ ../../inventoryClass/interface.nix ];
|
||||
_module.args = { inherit (self) clanLib; };
|
||||
});
|
||||
|
||||
opts = (flakeOptions.flake.type.getSubOptions [ "flake" ]);
|
||||
clanOpts = opts.clan.type.getSubOptions [ "clan" ];
|
||||
include = [
|
||||
@@ -38,13 +23,6 @@ let
|
||||
];
|
||||
clanSchema = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
|
||||
|
||||
renderSchema = pkgs.writers.writePython3Bin "render-schema" {
|
||||
flakeIgnore = [
|
||||
"F401"
|
||||
"E501"
|
||||
];
|
||||
} ./render_schema.py;
|
||||
|
||||
clan-schema-abstract = pkgs.stdenv.mkDerivation {
|
||||
name = "clan-schema-files";
|
||||
buildInputs = [ pkgs.cue ];
|
||||
@@ -63,29 +41,7 @@ in
|
||||
{
|
||||
inherit
|
||||
flakeOptions
|
||||
frontMatterSchema
|
||||
clanSchema
|
||||
inventorySchema
|
||||
modulesSchema
|
||||
renderSchema
|
||||
clan-schema-abstract
|
||||
;
|
||||
|
||||
# Inventory schema, with the modules schema added per role
|
||||
inventory =
|
||||
pkgs.runCommand "rendered"
|
||||
{
|
||||
buildInputs = [
|
||||
pkgs.python3
|
||||
self'.packages.clan-cli
|
||||
];
|
||||
}
|
||||
''
|
||||
export INVENTORY_SCHEMA_PATH=${builtins.toFile "inventory-schema.json" (builtins.toJSON inventorySchema)}
|
||||
export MODULES_SCHEMA_PATH=${builtins.toFile "modules-schema.json" (builtins.toJSON modulesSchema)}
|
||||
|
||||
mkdir $out
|
||||
# The python script will place the schemas in the output directory
|
||||
exec python3 ${renderSchema}/bin/render-schema
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Python script to join the abstract inventory schema, with the concrete clan modules
|
||||
Inventory has slots which are 'Any' type.
|
||||
We dont want to evaluate the clanModules interface in nix, when evaluating the inventory
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from clan_lib.errors import ClanError
|
||||
|
||||
# Get environment variables
|
||||
INVENTORY_SCHEMA_PATH = Path(os.environ["INVENTORY_SCHEMA_PATH"])
|
||||
|
||||
# { [moduleName] :: { [roleName] :: SCHEMA }}
|
||||
MODULES_SCHEMA_PATH = Path(os.environ["MODULES_SCHEMA_PATH"])
|
||||
|
||||
OUT = os.environ.get("out")
|
||||
|
||||
if not INVENTORY_SCHEMA_PATH:
|
||||
msg = f"Environment variables are not set correctly: INVENTORY_SCHEMA_PATH={INVENTORY_SCHEMA_PATH}."
|
||||
raise ClanError(msg)
|
||||
|
||||
if not MODULES_SCHEMA_PATH:
|
||||
msg = f"Environment variables are not set correctly: MODULES_SCHEMA_PATH={MODULES_SCHEMA_PATH}."
|
||||
raise ClanError(msg)
|
||||
|
||||
if not OUT:
|
||||
msg = f"Environment variables are not set correctly: OUT={OUT}."
|
||||
raise ClanError(msg)
|
||||
|
||||
|
||||
def service_roles_to_schema(
|
||||
schema: dict[str, Any],
|
||||
service_name: str,
|
||||
roles: list[str],
|
||||
roles_schemas: dict[str, dict[str, Any]],
|
||||
# Original service properties: {'config': Schema, 'machines': Schema, 'meta': Schema, 'extraModules': Schema, ...?}
|
||||
orig: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add roles to the service schema
|
||||
"""
|
||||
# collect all the roles for the service, to form a type union
|
||||
all_roles_schema: list[dict[str, Any]] = []
|
||||
for role_name, role_schema in roles_schemas.items():
|
||||
role_schema["title"] = f"{module_name}-config-role-{role_name}"
|
||||
all_roles_schema.append(role_schema)
|
||||
|
||||
role_schema = {}
|
||||
for role in roles:
|
||||
role_schema[role] = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
**orig["roles"]["additionalProperties"]["properties"],
|
||||
"config": {
|
||||
**roles_schemas.get(role, {}),
|
||||
"title": f"{service_name}-config-role-{role}",
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
machines_schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**orig["machines"]["additionalProperties"]["properties"],
|
||||
"config": {
|
||||
"title": f"{service_name}-config",
|
||||
"oneOf": all_roles_schema,
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
services["properties"][service_name] = {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
# Original inventory schema
|
||||
**orig,
|
||||
# Inject the roles schemas
|
||||
"roles": {
|
||||
"title": f"{service_name}-roles",
|
||||
"type": "object",
|
||||
"properties": role_schema,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"machines": machines_schema,
|
||||
"config": {
|
||||
"title": f"{service_name}-config",
|
||||
"oneOf": all_roles_schema,
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Joining inventory schema with modules schema")
|
||||
print(f"Inventory schema path: {INVENTORY_SCHEMA_PATH}")
|
||||
print(f"Modules schema path: {MODULES_SCHEMA_PATH}")
|
||||
|
||||
modules_schema = {}
|
||||
with Path.open(MODULES_SCHEMA_PATH) as f:
|
||||
modules_schema = json.load(f)
|
||||
|
||||
inventory_schema = {}
|
||||
with Path.open(INVENTORY_SCHEMA_PATH) as f:
|
||||
inventory_schema = json.load(f)
|
||||
|
||||
services = inventory_schema["properties"]["services"]
|
||||
original_service_props = services["additionalProperties"]["additionalProperties"][
|
||||
"properties"
|
||||
].copy()
|
||||
# Init the outer services schema
|
||||
# Properties (service names) will be filled in the next step
|
||||
services = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
# Service names
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
for module_name, roles_schemas in modules_schema.items():
|
||||
# Add the roles schemas to the service schema
|
||||
roles = list(roles_schemas.keys())
|
||||
if roles:
|
||||
services = service_roles_to_schema(
|
||||
services,
|
||||
module_name,
|
||||
roles,
|
||||
roles_schemas,
|
||||
original_service_props,
|
||||
)
|
||||
|
||||
inventory_schema["properties"]["services"] = services
|
||||
|
||||
outpath = Path(OUT)
|
||||
with (outpath / "schema.json").open("w") as f:
|
||||
json.dump(inventory_schema, f, indent=2)
|
||||
|
||||
with (outpath / "modules_schemas.json").open("w") as f:
|
||||
json.dump(modules_schema, f, indent=2)
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
clan-core,
|
||||
nix-darwin,
|
||||
lib,
|
||||
clanLib,
|
||||
}:
|
||||
let
|
||||
# TODO: Unify these tests with clan tests
|
||||
clan =
|
||||
m:
|
||||
lib.evalModules {
|
||||
specialArgs = { inherit clan-core nix-darwin clanLib; };
|
||||
modules = [
|
||||
clan-core.modules.clan.default
|
||||
{
|
||||
self = { };
|
||||
}
|
||||
m
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
test_inventory_a =
|
||||
let
|
||||
eval = clan {
|
||||
inventory = {
|
||||
machines = {
|
||||
A = { };
|
||||
};
|
||||
services = {
|
||||
legacyModule = { };
|
||||
};
|
||||
modules = {
|
||||
legacyModule = ./legacyModule;
|
||||
};
|
||||
};
|
||||
directory = ./.;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit eval;
|
||||
expr = {
|
||||
legacyModule = lib.filterAttrs (
|
||||
name: _: name == "isClanModule"
|
||||
) eval.config.clanInternals.inventoryClass.machines.A.compiledServices.legacyModule;
|
||||
};
|
||||
expected = {
|
||||
legacyModule = {
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test_inventory_empty =
|
||||
let
|
||||
eval = clan {
|
||||
inventory = { };
|
||||
directory = ./.;
|
||||
};
|
||||
in
|
||||
{
|
||||
# Empty inventory should return an empty module
|
||||
expr = eval.config.clanInternals.inventoryClass.machines;
|
||||
expected = { };
|
||||
};
|
||||
|
||||
test_inventory_module_doesnt_exist =
|
||||
let
|
||||
eval = clan {
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
services = {
|
||||
fanatasy.instance_1 = {
|
||||
roles.default.machines = [ "machine_1" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit eval;
|
||||
expr = eval.config.clanInternals.inventoryClass.machines.machine_1.machineImports;
|
||||
expectedError = {
|
||||
type = "ThrownError";
|
||||
msg = "ClanModule not found*";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
Description
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
clan-core,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Just some random stuff
|
||||
options.test = lib.mapAttrs clan-core;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# Integrity validation of the inventory
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
# Assertion must be of type
|
||||
# { assertion :: bool, message :: string, severity :: "error" | "warning" }
|
||||
imports = [
|
||||
# Check that each machine used in a service is defined in the top-level machines
|
||||
{
|
||||
assertions = lib.foldlAttrs (
|
||||
ass1: serviceName: c:
|
||||
ass1
|
||||
++ lib.foldlAttrs (
|
||||
ass2: instanceName: instanceConfig:
|
||||
let
|
||||
topLevelMachines = lib.attrNames config.machines;
|
||||
# All machines must be defined in the top-level machines
|
||||
assertions = lib.foldlAttrs (
|
||||
assertions: roleName: role:
|
||||
assertions
|
||||
++ builtins.filter (a: !a.assertion) (
|
||||
builtins.map (m: {
|
||||
assertion = builtins.elem m topLevelMachines;
|
||||
message = ''
|
||||
Machine '${m}' is not defined in the inventory. This might still work, if the machine is defined via nix.
|
||||
|
||||
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
|
||||
|
||||
Inventory machines:
|
||||
${builtins.concatStringsSep "\n" (map (n: "'${n}'") topLevelMachines)}
|
||||
'';
|
||||
severity = "warning";
|
||||
}) role.machines
|
||||
)
|
||||
) [ ] instanceConfig.roles;
|
||||
in
|
||||
ass2 ++ assertions
|
||||
) [ ] c
|
||||
) [ ] config.services;
|
||||
}
|
||||
# Check that each tag used in a role is defined in at least one machines tags
|
||||
{
|
||||
assertions = lib.foldlAttrs (
|
||||
ass1: serviceName: c:
|
||||
ass1
|
||||
++ lib.foldlAttrs (
|
||||
ass2: instanceName: instanceConfig:
|
||||
let
|
||||
allTags = lib.foldlAttrs (
|
||||
tags: _machineName: machine:
|
||||
tags ++ machine.tags
|
||||
) [ ] config.machines;
|
||||
# All machines must be defined in the top-level machines
|
||||
assertions = lib.foldlAttrs (
|
||||
assertions: roleName: role:
|
||||
assertions
|
||||
++ builtins.filter (a: !a.assertion) (
|
||||
builtins.map (m: {
|
||||
assertion = builtins.elem m allTags;
|
||||
message = ''
|
||||
Tag '${m}' is not defined in the inventory.
|
||||
|
||||
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
|
||||
|
||||
Available tags:
|
||||
${builtins.concatStringsSep "\n" (map (n: "'${n}'") allTags)}
|
||||
'';
|
||||
severity = "error";
|
||||
}) role.tags
|
||||
)
|
||||
) [ ] instanceConfig.roles;
|
||||
in
|
||||
ass2 ++ assertions
|
||||
) [ ] c
|
||||
) [ ] config.services;
|
||||
}
|
||||
];
|
||||
|
||||
}
|
||||
@@ -1,268 +1,5 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
clanLib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (config) inventory directory;
|
||||
resolveTags =
|
||||
# Inventory, { machines :: [string], tags :: [string] }
|
||||
{
|
||||
serviceName,
|
||||
instanceName,
|
||||
roleName,
|
||||
inventory,
|
||||
members,
|
||||
}:
|
||||
{
|
||||
machines =
|
||||
members.machines or [ ]
|
||||
++ (builtins.foldl' (
|
||||
acc: tag:
|
||||
let
|
||||
# For error printing
|
||||
availableTags = lib.foldlAttrs (
|
||||
acc: _: v:
|
||||
v.tags or [ ] ++ acc
|
||||
) [ ] (inventory.machines);
|
||||
|
||||
tagMembers = builtins.attrNames (
|
||||
lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) inventory.machines
|
||||
);
|
||||
in
|
||||
if tagMembers == [ ] then
|
||||
lib.warn ''
|
||||
inventory.services.${serviceName}.${instanceName}: - ${roleName} tags: no machine with tag '${tag}' found.
|
||||
Available tags: ${builtins.toJSON (lib.unique availableTags)}
|
||||
'' [ ]
|
||||
else
|
||||
acc ++ tagMembers
|
||||
) [ ] members.tags or [ ]);
|
||||
};
|
||||
|
||||
checkService =
|
||||
modulepath: serviceName:
|
||||
builtins.elem "inventory" (clanLib.modules.getFrontmatter modulepath serviceName).features or [ ];
|
||||
|
||||
compileMachine =
|
||||
{ machineConfig }:
|
||||
{
|
||||
machineImports = [
|
||||
(lib.optionalAttrs (machineConfig.deploy.targetHost or null != null) {
|
||||
config.clan.core.networking.targetHost = lib.mkForce machineConfig.deploy.targetHost;
|
||||
})
|
||||
(lib.optionalAttrs (machineConfig.deploy.buildHost or null != null) {
|
||||
config.clan.core.networking.buildHost = lib.mkForce machineConfig.deploy.buildHost;
|
||||
})
|
||||
];
|
||||
assertions = { };
|
||||
};
|
||||
|
||||
resolveImports =
|
||||
{
|
||||
supportedRoles,
|
||||
resolvedRolesPerInstance,
|
||||
serviceConfigs,
|
||||
serviceName,
|
||||
machineName,
|
||||
getRoleFile,
|
||||
}:
|
||||
(lib.foldlAttrs (
|
||||
# : [ Modules ] -> String -> ServiceConfig -> [ Modules ]
|
||||
acc2: instanceName: serviceConfig:
|
||||
let
|
||||
resolvedRoles = resolvedRolesPerInstance.${instanceName};
|
||||
|
||||
isInService = builtins.any (members: builtins.elem machineName members.machines) (
|
||||
builtins.attrValues resolvedRoles
|
||||
);
|
||||
|
||||
# all roles where the machine is present
|
||||
machineRoles = builtins.attrNames (
|
||||
lib.filterAttrs (_role: roleConfig: builtins.elem machineName roleConfig.machines) resolvedRoles
|
||||
);
|
||||
|
||||
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
|
||||
globalConfig = serviceConfig.config or { };
|
||||
|
||||
globalExtraModules = serviceConfig.extraModules or [ ];
|
||||
machineExtraModules = serviceConfig.machines.${machineName}.extraModules or [ ];
|
||||
roleServiceExtraModules = builtins.foldl' (
|
||||
acc: role: acc ++ serviceConfig.roles.${role}.extraModules or [ ]
|
||||
) [ ] machineRoles;
|
||||
|
||||
# TODO: maybe optimize this don't lookup the role in inverse roles. Imports are not lazy
|
||||
roleModules = builtins.map (
|
||||
role:
|
||||
if builtins.elem role supportedRoles && inventory.modules ? ${serviceName} then
|
||||
getRoleFile role
|
||||
else
|
||||
throw "Module ${serviceName} doesn't have role: '${role}'. Role: ${
|
||||
inventory.modules.${serviceName}
|
||||
}/roles/${role}.nix not found."
|
||||
) machineRoles;
|
||||
|
||||
roleServiceConfigs = builtins.filter (m: m != { }) (
|
||||
builtins.map (role: serviceConfig.roles.${role}.config or { }) machineRoles
|
||||
);
|
||||
|
||||
extraModules = map (s: if builtins.typeOf s == "string" then "${directory}/${s}" else s) (
|
||||
globalExtraModules ++ machineExtraModules ++ roleServiceExtraModules
|
||||
);
|
||||
|
||||
features =
|
||||
(clanLib.modules.getFrontmatter inventory.modules.${serviceName} serviceName).features or [ ];
|
||||
deprecationWarning = lib.optionalAttrs (builtins.elem "deprecated" features) {
|
||||
warnings = [
|
||||
''
|
||||
The '${serviceName}' module has been migrated from `inventory.services` to `inventory.instances`
|
||||
See https://docs.clan.lol/guides/clanServices/ for usage.
|
||||
''
|
||||
];
|
||||
};
|
||||
in
|
||||
if !(serviceConfig.enabled or true) then
|
||||
acc2
|
||||
else if isInService then
|
||||
acc2
|
||||
++ [
|
||||
deprecationWarning
|
||||
{
|
||||
imports = roleModules ++ extraModules;
|
||||
clan.inventory.services.${serviceName}.${instanceName} = {
|
||||
roles = resolvedRoles;
|
||||
# TODO: Add inverseRoles to the service config if needed
|
||||
# inherit inverseRoles;
|
||||
};
|
||||
}
|
||||
(lib.optionalAttrs (globalConfig != { } || machineServiceConfig != { } || roleServiceConfigs != [ ])
|
||||
{
|
||||
clan.${serviceName} = lib.mkMerge (
|
||||
[
|
||||
globalConfig
|
||||
machineServiceConfig
|
||||
]
|
||||
++ roleServiceConfigs
|
||||
);
|
||||
}
|
||||
)
|
||||
]
|
||||
else
|
||||
acc2
|
||||
) [ ] (serviceConfigs));
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./interface.nix
|
||||
];
|
||||
config = {
|
||||
machines = builtins.mapAttrs (
|
||||
machineName: machineConfig: m:
|
||||
let
|
||||
compiledServices = lib.mapAttrs (
|
||||
_: serviceConfigs:
|
||||
(
|
||||
{ config, ... }:
|
||||
let
|
||||
serviceName = config.serviceName;
|
||||
|
||||
getRoleFile = role: builtins.seq role inventory.modules.${serviceName} + "/roles/${role}.nix";
|
||||
in
|
||||
{
|
||||
_file = "inventory/builder.nix";
|
||||
_module.args = {
|
||||
inherit
|
||||
resolveTags
|
||||
inventory
|
||||
clanLib
|
||||
machineName
|
||||
serviceConfigs
|
||||
;
|
||||
};
|
||||
imports = [
|
||||
./roles.nix
|
||||
];
|
||||
|
||||
machineImports = resolveImports {
|
||||
supportedRoles = config.supportedRoles;
|
||||
resolvedRolesPerInstance = config.resolvedRolesPerInstance;
|
||||
inherit
|
||||
serviceConfigs
|
||||
serviceName
|
||||
machineName
|
||||
getRoleFile
|
||||
;
|
||||
};
|
||||
|
||||
# Assertions
|
||||
assertions = {
|
||||
"checkservice.${serviceName}" = {
|
||||
assertion = checkService inventory.modules.${serviceName} serviceName;
|
||||
message = ''
|
||||
Service ${serviceName} cannot be used in inventory. It does not declare the 'inventory' feature.
|
||||
|
||||
To allow it add the following to the beginning of the README.md of the module:
|
||||
|
||||
---
|
||||
...
|
||||
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
|
||||
Also make sure to test the module with the 'inventory' feature enabled.
|
||||
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
) (config.inventory.services or { });
|
||||
|
||||
compiledMachine = compileMachine {
|
||||
inherit
|
||||
machineConfig
|
||||
;
|
||||
};
|
||||
|
||||
machineImports = (
|
||||
compiledMachine.machineImports
|
||||
++ builtins.foldl' (
|
||||
acc: service:
|
||||
let
|
||||
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) service.assertions);
|
||||
failedAssertionsImports =
|
||||
if failedAssertions != { } then
|
||||
[
|
||||
{
|
||||
clan.inventory.assertions = failedAssertions;
|
||||
}
|
||||
]
|
||||
else
|
||||
[
|
||||
{
|
||||
clan.inventory.assertions = {
|
||||
"alive.assertion.inventory" = {
|
||||
assertion = true;
|
||||
message = ''
|
||||
No failed assertions found for machine ${machineName}. This will never be displayed.
|
||||
It is here for testing purposes.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
in
|
||||
acc
|
||||
++ service.machineImports
|
||||
# Import failed assertions
|
||||
++ failedAssertionsImports
|
||||
) [ ] (builtins.attrValues m.config.compiledServices)
|
||||
);
|
||||
in
|
||||
{
|
||||
inherit machineImports compiledServices compiledMachine;
|
||||
}
|
||||
) (inventory.machines or { });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,76 +16,13 @@ in
|
||||
type = types.raw;
|
||||
};
|
||||
machines = mkOption {
|
||||
type = types.attrsOf (
|
||||
submodule (
|
||||
{ name, ... }:
|
||||
let
|
||||
machineName = name;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
compiledMachine = mkOption {
|
||||
type = types.raw;
|
||||
};
|
||||
compiledServices = mkOption {
|
||||
# type = types.attrsOf;
|
||||
type = types.attrsOf (
|
||||
types.submoduleWith {
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
let
|
||||
serviceName = name;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
machineName = mkOption {
|
||||
default = machineName;
|
||||
readOnly = true;
|
||||
};
|
||||
serviceName = mkOption {
|
||||
default = serviceName;
|
||||
readOnly = true;
|
||||
};
|
||||
# Outputs
|
||||
machineImports = mkOption {
|
||||
type = types.listOf types.raw;
|
||||
};
|
||||
supportedRoles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
matchedRoles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
machinesRoles = mkOption {
|
||||
type = types.attrsOf (types.listOf types.str);
|
||||
};
|
||||
resolvedRolesPerInstance = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.attrsOf (submodule {
|
||||
options.machines = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
assertions = mkOption {
|
||||
type = types.attrsOf types.raw;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
machineImports = mkOption {
|
||||
type = types.listOf types.raw;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
type = types.attrsOf (submodule ({
|
||||
options = {
|
||||
machineImports = mkOption {
|
||||
type = types.listOf types.raw;
|
||||
};
|
||||
};
|
||||
}));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
resolveTags,
|
||||
inventory,
|
||||
clanLib,
|
||||
machineName,
|
||||
serviceConfigs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
serviceName = config.serviceName;
|
||||
in
|
||||
{
|
||||
# Roles resolution
|
||||
# : List String
|
||||
supportedRoles = clanLib.modules.getRoles "inventory.modules" inventory.modules serviceName;
|
||||
matchedRoles = builtins.attrNames (
|
||||
lib.filterAttrs (_: ms: builtins.elem machineName ms) config.machinesRoles
|
||||
);
|
||||
resolvedRolesPerInstance = lib.mapAttrs (
|
||||
instanceName: instanceConfig:
|
||||
let
|
||||
resolvedRoles = lib.genAttrs config.supportedRoles (
|
||||
roleName:
|
||||
resolveTags {
|
||||
members = instanceConfig.roles.${roleName} or { };
|
||||
inherit
|
||||
instanceName
|
||||
serviceName
|
||||
roleName
|
||||
inventory
|
||||
;
|
||||
}
|
||||
);
|
||||
usedRoles = builtins.attrNames instanceConfig.roles;
|
||||
unmatchedRoles = builtins.filter (role: !builtins.elem role config.supportedRoles) usedRoles;
|
||||
in
|
||||
if unmatchedRoles != [ ] then
|
||||
throw ''
|
||||
Roles ${builtins.toJSON unmatchedRoles} are not defined in the service ${serviceName}.
|
||||
Instance: '${instanceName}'
|
||||
Please use one of available roles: ${builtins.toJSON config.supportedRoles}
|
||||
''
|
||||
else
|
||||
resolvedRoles
|
||||
) serviceConfigs;
|
||||
|
||||
machinesRoles = builtins.zipAttrsWith (
|
||||
_n: vs:
|
||||
let
|
||||
flat = builtins.foldl' (acc: s: acc ++ s.machines) [ ] vs;
|
||||
in
|
||||
lib.unique flat
|
||||
) (builtins.attrValues config.resolvedRolesPerInstance);
|
||||
|
||||
assertions = lib.concatMapAttrs (
|
||||
instanceName: resolvedRoles:
|
||||
clanLib.modules.checkConstraints {
|
||||
moduleName = serviceName;
|
||||
allModules = inventory.modules;
|
||||
inherit resolvedRoles instanceName;
|
||||
}
|
||||
) config.resolvedRolesPerInstance;
|
||||
}
|
||||
@@ -31,70 +31,13 @@ let
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
moduleConfig = lib.mkOption {
|
||||
default = { };
|
||||
# TODO: use types.deferredModule
|
||||
# clan.borgbackup MUST be defined as submodule
|
||||
type = types.attrsOf types.anything;
|
||||
description = ''
|
||||
Configuration of the specific clanModule.
|
||||
|
||||
!!! Note
|
||||
Configuration is passed to the nixos configuration scoped to the module.
|
||||
|
||||
```nix
|
||||
clan.<serviceName> = { ... # Config }
|
||||
```
|
||||
'';
|
||||
};
|
||||
|
||||
extraModulesOption = lib.mkOption {
|
||||
description = ''
|
||||
List of additionally imported `.nix` expressions.
|
||||
|
||||
Supported types:
|
||||
|
||||
- **Strings**: Interpreted relative to the 'directory' passed to `lib.clan`.
|
||||
- **Paths**: should be relative to the current file.
|
||||
- **Any**: Nix expression must be serializable to JSON.
|
||||
|
||||
!!! Note
|
||||
**The import only happens if the machine is part of the service or role.**
|
||||
|
||||
Other types are passed through to the nixos configuration.
|
||||
|
||||
???+ Example
|
||||
To import the `special.nix` file
|
||||
|
||||
```
|
||||
. Clan Directory
|
||||
├── flake.nix
|
||||
...
|
||||
└── modules
|
||||
├── special.nix
|
||||
└── ...
|
||||
```
|
||||
|
||||
```nix
|
||||
{
|
||||
extraModules = [ "modules/special.nix" ];
|
||||
}
|
||||
```
|
||||
'';
|
||||
apply = value: if lib.isString value then value else builtins.seq (builtins.toJSON value) value;
|
||||
default = [ ];
|
||||
type = types.listOf (
|
||||
types.oneOf [
|
||||
types.str
|
||||
types.anything
|
||||
]
|
||||
);
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./assertions.nix
|
||||
(lib.mkRemovedOptionModule [ "services" ] ''
|
||||
The `inventory.services` option has been removed. Use `inventory.instances` instead.
|
||||
See: https://docs.clan.lol/concepts/inventory/#services
|
||||
'')
|
||||
];
|
||||
options = {
|
||||
# Internal things
|
||||
@@ -312,6 +255,16 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
installedAt = lib.mkOption {
|
||||
type = types.nullOr types.int;
|
||||
default = null;
|
||||
description = ''
|
||||
Indicates when the machine was first installed.
|
||||
|
||||
Timestamp is in unix time (seconds since epoch).
|
||||
'';
|
||||
};
|
||||
|
||||
tags = lib.mkOption {
|
||||
description = ''
|
||||
List of tags for the machine.
|
||||
@@ -415,160 +368,5 @@ in
|
||||
);
|
||||
default = { };
|
||||
};
|
||||
|
||||
services = lib.mkOption {
|
||||
# TODO: deprecate these options
|
||||
# services are deprecated in favor of `instances`
|
||||
# visible = false;
|
||||
description = ''
|
||||
Services of the inventory.
|
||||
|
||||
- The first `<name>` is the moduleName. It must be a valid clanModule name.
|
||||
- The second `<name>` is an arbitrary instance name.
|
||||
|
||||
???+ Example
|
||||
```nix
|
||||
# ClanModule name. See the module documentation for the available modules.
|
||||
# ↓ ↓ Instance name, can be anything, some services might use it as a unique identifier.
|
||||
services.borgbackup."instance_1" = {
|
||||
roles.client.machines = ["machineA"];
|
||||
};
|
||||
```
|
||||
|
||||
!!! Note
|
||||
Services MUST be added to machines via `roles` exclusively.
|
||||
See [`roles.<rolename>.machines`](#inventory.services.roles.machines) or [`roles.<rolename>.tags`](#inventory.services.roles.tags) for more information.
|
||||
'';
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.attrsOf (
|
||||
types.submodule (
|
||||
# instance name
|
||||
{ name, ... }:
|
||||
{
|
||||
options.enabled = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable or disable the complete service.
|
||||
|
||||
If the service is disabled, it will not be added to any machine.
|
||||
|
||||
!!! Note
|
||||
This flag is primarily used to temporarily disable a service.
|
||||
I.e. A 'backup service' without any 'server' might be incomplete and would cause failure if enabled.
|
||||
'';
|
||||
};
|
||||
options.meta = metaOptionsWith name;
|
||||
options.extraModules = extraModulesOption;
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Configuration of the specific clanModule.
|
||||
|
||||
!!! Note
|
||||
Configuration is passed to the nixos configuration scoped to the module.
|
||||
|
||||
```nix
|
||||
clan.<serviceName> = { ... # Config }
|
||||
```
|
||||
|
||||
???+ Example
|
||||
|
||||
For `services.borgbackup` the config is the passed to the machine with the prefix of `clan.borgbackup`.
|
||||
This means all config values are mapped to the `borgbackup` clanModule exclusively (`config.clan.borgbackup`).
|
||||
|
||||
```nix
|
||||
{
|
||||
services.borgbackup."instance_1".config = {
|
||||
destinations = [ ... ];
|
||||
# See the 'borgbackup' module docs for all options
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
!!! Note
|
||||
The module author is responsible for supporting multiple instance configurations in different roles.
|
||||
See each clanModule's documentation for more information.
|
||||
'';
|
||||
};
|
||||
options.machines = lib.mkOption {
|
||||
description = ''
|
||||
Attribute set of machines specific config for the service.
|
||||
|
||||
Will be merged with other service configs, such as the role config and the global config.
|
||||
For machine specific overrides use `mkForce` or other higher priority methods.
|
||||
|
||||
???+ Example
|
||||
|
||||
```{.nix hl_lines="4-7"}
|
||||
services.borgbackup."instance_1" = {
|
||||
roles.client.machines = ["machineA"];
|
||||
|
||||
machines.machineA.config = {
|
||||
# Additional specific config for the machine
|
||||
# This is merged with all other config places
|
||||
};
|
||||
};
|
||||
```
|
||||
'';
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.extraModules = extraModulesOption;
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Additional configuration of the specific machine.
|
||||
|
||||
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
options.roles = lib.mkOption {
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.machines = lib.mkOption {
|
||||
default = [ ];
|
||||
type = types.listOf types.str;
|
||||
example = [ "machineA" ];
|
||||
description = ''
|
||||
List of machines which are part of the role.
|
||||
|
||||
The machines are referenced by their `attributeName` in the `inventory.machines` attribute set.
|
||||
|
||||
Memberships are declared here to determine which machines are part of the service.
|
||||
|
||||
Alternatively, `tags` can be used to determine the membership, more dynamically.
|
||||
'';
|
||||
};
|
||||
options.tags = lib.mkOption {
|
||||
default = [ ];
|
||||
apply = lib.unique;
|
||||
type = types.listOf types.str;
|
||||
description = ''
|
||||
List of tags which are used to determine the membership of the role.
|
||||
|
||||
The tags are matched against the `inventory.machines.<machineName>.tags` attribute set.
|
||||
If a machine has at least one tag of the role, it is part of the role.
|
||||
'';
|
||||
};
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Additional configuration of the specific role.
|
||||
|
||||
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
|
||||
'';
|
||||
};
|
||||
options.extraModules = extraModulesOption;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
default =
|
||||
builtins.removeAttrs (clanLib.introspection.getPrios { options = config.inventory.options; })
|
||||
# tags are freeformType which is not supported yet.
|
||||
[ "tags" ];
|
||||
# services is removed and throws an error if accessed.
|
||||
[
|
||||
"tags"
|
||||
"services"
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,9 +290,11 @@ in
|
||||
};
|
||||
owner = mkOption {
|
||||
description = "The user name or id that will own the file.";
|
||||
type = str;
|
||||
default = "root";
|
||||
};
|
||||
group = mkOption {
|
||||
type = str;
|
||||
description = "The group name or id that will own the file.";
|
||||
default = if _class == "darwin" then "wheel" else "root";
|
||||
defaultText = lib.literalExpression ''if _class == "darwin" then "wheel" else "root"'';
|
||||
|
||||
116
nixosModules/clanCore/vm-base.nix
Normal file
116
nixosModules/clanCore/vm-base.nix
Normal file
@@ -0,0 +1,116 @@
|
||||
# Standalone VM base module that can be imported independently
|
||||
# This module contains the core VM configuration without the system extension
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Flatten the list of state folders into a single list
|
||||
stateFolders = lib.flatten (
|
||||
lib.mapAttrsToList (_item: attrs: attrs.folders) config.clan.core.state
|
||||
);
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/qemu-vm.nix")
|
||||
./serial.nix
|
||||
./waypipe.nix
|
||||
];
|
||||
|
||||
clan.core.state.HOME.folders = [ "/home" ];
|
||||
|
||||
clan.services.waypipe = {
|
||||
inherit (config.clan.core.vm.inspect.waypipe) enable command;
|
||||
};
|
||||
|
||||
# required for issuing shell commands via qga
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
# required to react to system_powerdown qmp command
|
||||
# Some desktop managers like xfce override the poweroff signal and therefore
|
||||
# make it impossible to handle it via 'logind' directly.
|
||||
services.acpid.enable = true;
|
||||
services.acpid.handlers.power.event = "button/power.*";
|
||||
services.acpid.handlers.power.action = "poweroff";
|
||||
|
||||
# only works on x11
|
||||
services.spice-vdagentd.enable = config.services.xserver.enable;
|
||||
|
||||
boot.initrd.systemd.enable = true;
|
||||
|
||||
boot.initrd.systemd.storePaths = [
|
||||
pkgs.util-linux
|
||||
pkgs.e2fsprogs
|
||||
];
|
||||
boot.initrd.systemd.emergencyAccess = true;
|
||||
|
||||
# userborn would be faster because it doesn't need perl, but it cannot create normal users
|
||||
services.userborn.enable = true;
|
||||
users.mutableUsers = false;
|
||||
users.allowNoPasswordLogin = true;
|
||||
|
||||
boot.initrd.kernelModules = [ "virtiofs" ];
|
||||
virtualisation.writableStore = false;
|
||||
virtualisation.fileSystems = lib.mkForce (
|
||||
{
|
||||
"/nix/store" = {
|
||||
device = "nix-store";
|
||||
options = [
|
||||
"x-systemd.requires=systemd-modules-load.service"
|
||||
"ro"
|
||||
];
|
||||
fsType = "virtiofs";
|
||||
};
|
||||
|
||||
"/" = {
|
||||
device = "/dev/vda";
|
||||
fsType = "ext4";
|
||||
options = [
|
||||
"defaults"
|
||||
"x-systemd.makefs"
|
||||
"nobarrier"
|
||||
"noatime"
|
||||
"nodiratime"
|
||||
"data=writeback"
|
||||
"discard"
|
||||
];
|
||||
};
|
||||
|
||||
"/vmstate" = {
|
||||
device = "/dev/vdb";
|
||||
options = [
|
||||
"x-systemd.makefs"
|
||||
"noatime"
|
||||
"nodiratime"
|
||||
"discard"
|
||||
];
|
||||
noCheck = true;
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
${config.clan.core.facts.secretUploadDirectory} = {
|
||||
device = "secrets";
|
||||
fsType = "9p";
|
||||
neededForBoot = true;
|
||||
options = [
|
||||
"trans=virtio"
|
||||
"version=9p2000.L"
|
||||
"cache=loose"
|
||||
];
|
||||
};
|
||||
}
|
||||
// lib.listToAttrs (
|
||||
map (
|
||||
folder:
|
||||
lib.nameValuePair folder {
|
||||
device = "/vmstate${folder}";
|
||||
fsType = "none";
|
||||
options = [ "bind" ];
|
||||
}
|
||||
) stateFolders
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4,116 +4,11 @@
|
||||
pkgs,
|
||||
options,
|
||||
extendModules,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Flatten the list of state folders into a single list
|
||||
stateFolders = lib.flatten (
|
||||
lib.mapAttrsToList (_item: attrs: attrs.folders) config.clan.core.state
|
||||
);
|
||||
|
||||
vmModule = {
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/qemu-vm.nix")
|
||||
./serial.nix
|
||||
./waypipe.nix
|
||||
];
|
||||
|
||||
clan.core.state.HOME.folders = [ "/home" ];
|
||||
|
||||
clan.services.waypipe = {
|
||||
inherit (config.clan.core.vm.inspect.waypipe) enable command;
|
||||
};
|
||||
|
||||
# required for issuing shell commands via qga
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
# required to react to system_powerdown qmp command
|
||||
# Some desktop managers like xfce override the poweroff signal and therefore
|
||||
# make it impossible to handle it via 'logind' directly.
|
||||
services.acpid.enable = true;
|
||||
services.acpid.handlers.power.event = "button/power.*";
|
||||
services.acpid.handlers.power.action = "poweroff";
|
||||
|
||||
# only works on x11
|
||||
services.spice-vdagentd.enable = config.services.xserver.enable;
|
||||
|
||||
boot.initrd.systemd.enable = true;
|
||||
|
||||
boot.initrd.systemd.storePaths = [
|
||||
pkgs.util-linux
|
||||
pkgs.e2fsprogs
|
||||
];
|
||||
boot.initrd.systemd.emergencyAccess = true;
|
||||
|
||||
# userborn would be faster because it doesn't need perl, but it cannot create normal users
|
||||
services.userborn.enable = true;
|
||||
users.mutableUsers = false;
|
||||
users.allowNoPasswordLogin = true;
|
||||
|
||||
boot.initrd.kernelModules = [ "virtiofs" ];
|
||||
virtualisation.writableStore = false;
|
||||
virtualisation.fileSystems = lib.mkForce (
|
||||
{
|
||||
"/nix/store" = {
|
||||
device = "nix-store";
|
||||
options = [
|
||||
"x-systemd.requires=systemd-modules-load.service"
|
||||
"ro"
|
||||
];
|
||||
fsType = "virtiofs";
|
||||
};
|
||||
|
||||
"/" = {
|
||||
device = "/dev/vda";
|
||||
fsType = "ext4";
|
||||
options = [
|
||||
"defaults"
|
||||
"x-systemd.makefs"
|
||||
"nobarrier"
|
||||
"noatime"
|
||||
"nodiratime"
|
||||
"data=writeback"
|
||||
"discard"
|
||||
];
|
||||
};
|
||||
|
||||
"/vmstate" = {
|
||||
device = "/dev/vdb";
|
||||
options = [
|
||||
"x-systemd.makefs"
|
||||
"noatime"
|
||||
"nodiratime"
|
||||
"discard"
|
||||
];
|
||||
noCheck = true;
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
${config.clan.core.facts.secretUploadDirectory} = {
|
||||
device = "secrets";
|
||||
fsType = "9p";
|
||||
neededForBoot = true;
|
||||
options = [
|
||||
"trans=virtio"
|
||||
"version=9p2000.L"
|
||||
"cache=loose"
|
||||
];
|
||||
};
|
||||
}
|
||||
// lib.listToAttrs (
|
||||
map (
|
||||
folder:
|
||||
lib.nameValuePair folder {
|
||||
device = "/vmstate${folder}";
|
||||
fsType = "none";
|
||||
options = [ "bind" ];
|
||||
}
|
||||
) stateFolders
|
||||
)
|
||||
);
|
||||
};
|
||||
# Import the standalone VM base module
|
||||
vmModule = import ./vm-base.nix;
|
||||
|
||||
# We cannot simply merge the VM config into the current system config, because
|
||||
# it is not necessarily a VM.
|
||||
|
||||
@@ -34,4 +34,7 @@ in
|
||||
|
||||
flake.nixosModules.clanCore = clanCore;
|
||||
flake.darwinModules.clanCore = clanCore;
|
||||
|
||||
# Standalone VM base module that can be imported for VM testing
|
||||
flake.nixosModules.clan-vm-base = ./clanCore/vm-base.nix;
|
||||
}
|
||||
|
||||
3
pkgs/clan-app/ui/.gitignore
vendored
3
pkgs/clan-app/ui/.gitignore
vendored
@@ -2,4 +2,5 @@ app/api
|
||||
app/.fonts
|
||||
|
||||
.vite
|
||||
storybook-static
|
||||
storybook-static
|
||||
*.css.d.ts
|
||||
6223
pkgs/clan-app/ui/api/Inventory.ts
Normal file
6223
pkgs/clan-app/ui/api/Inventory.ts
Normal file
File diff suppressed because it is too large
Load Diff
BIN
pkgs/clan-app/ui/logos/usb-stick-min.png
Normal file
BIN
pkgs/clan-app/ui/logos/usb-stick-min.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
1052
pkgs/clan-app/ui/package-lock.json
generated
1052
pkgs/clan-app/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,9 @@
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"typescript-plugin-css-modules": "^5.2.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-css-modules": "^1.10.0",
|
||||
"vite-plugin-solid": "^2.8.2",
|
||||
"vite-plugin-solid-svg": "^0.8.1",
|
||||
"vitest": "^3.2.3",
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
div.alert {
|
||||
.alert {
|
||||
@apply flex flex-row gap-2.5 p-4 rounded-md items-start;
|
||||
|
||||
&.has-icon {
|
||||
&.hasIcon {
|
||||
@apply pl-3;
|
||||
|
||||
svg.icon {
|
||||
@apply relative top-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-dismiss {
|
||||
@apply pr-3;
|
||||
}
|
||||
|
||||
& > button.dismiss-trigger {
|
||||
&.hasIcon svg.icon {
|
||||
@apply relative top-0.5;
|
||||
}
|
||||
|
||||
& > div.content {
|
||||
@apply flex flex-col size-full gap-1;
|
||||
&.hasDismiss {
|
||||
@apply pr-3;
|
||||
}
|
||||
|
||||
&.info {
|
||||
@apply bg-semantic-info-1 border border-semantic-info-3 fg-semantic-info-3;
|
||||
}
|
||||
|
||||
&.error {
|
||||
@apply bg-semantic-error-2 border border-semantic-error-3 fg-semantic-error-3;
|
||||
}
|
||||
@@ -38,6 +29,18 @@ div.alert {
|
||||
}
|
||||
|
||||
&.transparent {
|
||||
@apply bg-transparent border-none p-0;
|
||||
@apply bg-transparent border-none;
|
||||
}
|
||||
|
||||
&.noPadding {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
|
||||
.alertContent {
|
||||
@apply flex flex-col size-full gap-1;
|
||||
}
|
||||
|
||||
.dismissTrigger {
|
||||
@apply relative top-0.5;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import "./Alert.css";
|
||||
import cx from "classnames";
|
||||
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@kobalte/core/button";
|
||||
import { Alert as KAlert } from "@kobalte/core/alert";
|
||||
import { Show } from "solid-js";
|
||||
import styles from "./Alert.module.css";
|
||||
|
||||
export interface AlertProps {
|
||||
icon?: IconVariant;
|
||||
@@ -13,6 +13,7 @@ export interface AlertProps {
|
||||
title: string;
|
||||
onDismiss?: () => void;
|
||||
transparent?: boolean;
|
||||
dense?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -24,16 +25,17 @@ export const Alert = (props: AlertProps) => {
|
||||
|
||||
return (
|
||||
<KAlert
|
||||
class={cx("alert", props.type, {
|
||||
"has-icon": props.icon,
|
||||
"has-dismiss": props.onDismiss,
|
||||
transparent: props.transparent,
|
||||
class={cx(styles.alert, styles[props.type], {
|
||||
[styles.hasIcon]: props.icon,
|
||||
[styles.hasDismiss]: props.onDismiss,
|
||||
[styles.transparent]: props.transparent,
|
||||
[styles.noPadding]: props.dense,
|
||||
})}
|
||||
>
|
||||
{props.icon && (
|
||||
<Icon icon={props.icon} color="inherit" size={iconSize()} />
|
||||
)}
|
||||
<div class="content">
|
||||
<div class={styles.alertContent}>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
family="condensed"
|
||||
@@ -57,7 +59,7 @@ export const Alert = (props: AlertProps) => {
|
||||
{props.onDismiss && (
|
||||
<Button
|
||||
name="dismiss-alert"
|
||||
class="dismiss-trigger"
|
||||
class={styles.dismissTrigger}
|
||||
onClick={props.onDismiss}
|
||||
aria-label={`Dismiss ${props.type} alert`}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
.button {
|
||||
@apply flex gap-2 shrink-0 items-center justify-center;
|
||||
@apply px-4 py-2;
|
||||
|
||||
height: theme(height.9);
|
||||
border-radius: 3px;
|
||||
@apply h-[2.125rem] px-4 py-2 rounded-[0.1875rem];
|
||||
|
||||
/* Add transition for smooth width animation */
|
||||
transition: width 0.5s ease 0.1s;
|
||||
|
||||
&.s {
|
||||
@apply px-3 py-1.5;
|
||||
height: theme(height.7);
|
||||
border-radius: 2px;
|
||||
@apply h-[1.625rem] px-3 py-1.5 rounded-[0.125rem];
|
||||
|
||||
&:has(> .icon-start):has(> .label) {
|
||||
@apply pl-2;
|
||||
@@ -22,6 +17,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.xs {
|
||||
@apply h-[1.125rem] gap-0.5 p-2 rounded-[0.125rem];
|
||||
|
||||
&:has(> .icon-start):has(> .label) {
|
||||
@apply pl-1.5;
|
||||
}
|
||||
|
||||
&:has(> .icon-end):has(> .label) {
|
||||
@apply pr-1.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
@apply bg-inv-acc-4 fg-inv-1;
|
||||
@apply border border-solid border-inv-4;
|
||||
|
||||
@@ -8,7 +8,7 @@ const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
|
||||
|
||||
const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
<>
|
||||
<div class="grid w-fit grid-cols-4 gap-8">
|
||||
<div class="grid w-fit grid-cols-6 gap-8">
|
||||
<div>
|
||||
<Button data-testid="default" {...props}>
|
||||
Label
|
||||
@@ -19,6 +19,11 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button data-testid="xsmall" size="xs" {...props}>
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button data-testid="default-disabled" {...props} disabled={true}>
|
||||
Disabled
|
||||
@@ -35,6 +40,17 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-disabled"
|
||||
{...props}
|
||||
disabled={true}
|
||||
size="xs"
|
||||
>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button data-testid="default-start-icon" {...props} startIcon="Flash">
|
||||
Label
|
||||
@@ -50,6 +66,16 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-start-icon"
|
||||
{...props}
|
||||
startIcon="Flash"
|
||||
size="xs"
|
||||
>
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="default-disabled-start-icon"
|
||||
@@ -72,6 +98,18 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-disabled-start-icon"
|
||||
{...props}
|
||||
startIcon="Flash"
|
||||
size="xs"
|
||||
disabled={true}
|
||||
>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button data-testid="default-end-icon" {...props} endIcon="Flash">
|
||||
Label
|
||||
@@ -87,6 +125,16 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-end-icon"
|
||||
{...props}
|
||||
endIcon="Flash"
|
||||
size="xs"
|
||||
>
|
||||
Label
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="default-disabled-end-icon"
|
||||
@@ -108,12 +156,27 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-disabled-end-icon"
|
||||
{...props}
|
||||
endIcon="Flash"
|
||||
size="xs"
|
||||
disabled={true}
|
||||
>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button data-testid="default-icon" {...props} icon="Flash" />
|
||||
</div>
|
||||
<div>
|
||||
<Button data-testid="small-icon" {...props} icon="Flash" size="s" />
|
||||
</div>
|
||||
<div>
|
||||
<Button data-testid="xsmall-icon" {...props} icon="Flash" size="xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="default-disabled-icon"
|
||||
@@ -131,6 +194,15 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
|
||||
size="s"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="xsmall-disabled-icon"
|
||||
{...props}
|
||||
icon="Flash"
|
||||
disabled={true}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import "./Button.css";
|
||||
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
|
||||
import { Loader } from "@/src/components/Loader/Loader";
|
||||
|
||||
export type Size = "default" | "s";
|
||||
export type Size = "default" | "s" | "xs";
|
||||
export type Hierarchy = "primary" | "secondary";
|
||||
|
||||
export type Action = () => Promise<void>;
|
||||
@@ -28,6 +28,7 @@ export interface ButtonProps
|
||||
const iconSizes: Record<Size, string> = {
|
||||
default: "1rem",
|
||||
s: "0.8125rem",
|
||||
xs: "0.625rem",
|
||||
};
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface LabelProps {
|
||||
}
|
||||
|
||||
export const Label = (props: LabelProps) => {
|
||||
const descriptionSize = () => (props.size == "default" ? "xs" : "xxs");
|
||||
const descriptionSize = () => (props.size == "default" ? "s" : "xs");
|
||||
|
||||
return (
|
||||
<Show when={props.label}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
span.machine-status {
|
||||
@apply flex items-center gap-1;
|
||||
@apply flex items-center gap-1.5;
|
||||
|
||||
.indicator {
|
||||
@apply w-1.5 h-1.5 rounded-full m-1.5;
|
||||
@@ -13,7 +13,7 @@ span.machine-status {
|
||||
background-color: theme(colors.fg.semantic.error.1);
|
||||
}
|
||||
|
||||
&.installed > .indicator {
|
||||
&.out-of-sync > .indicator {
|
||||
background-color: theme(colors.fg.inv.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,27 +20,38 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<MachineStatusProps>;
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Online: Story = {
|
||||
args: {
|
||||
status: "Online",
|
||||
status: "online",
|
||||
},
|
||||
};
|
||||
|
||||
export const Offline: Story = {
|
||||
args: {
|
||||
status: "Offline",
|
||||
status: "offline",
|
||||
},
|
||||
};
|
||||
|
||||
export const Installed: Story = {
|
||||
export const OutOfSync: Story = {
|
||||
args: {
|
||||
status: "Installed",
|
||||
status: "out_of_sync",
|
||||
},
|
||||
};
|
||||
|
||||
export const NotInstalled: Story = {
|
||||
args: {
|
||||
status: "Not Installed",
|
||||
status: "not_installed",
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingWithLabel: Story = {
|
||||
args: {
|
||||
...Loading.args,
|
||||
label: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +71,7 @@ export const OfflineWithLabel: Story = {
|
||||
|
||||
export const InstalledWithLabel: Story = {
|
||||
args: {
|
||||
...Installed.args,
|
||||
...OutOfSync.args,
|
||||
label: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,41 +2,58 @@ import "./MachineStatus.css";
|
||||
|
||||
import { Badge } from "@kobalte/core/badge";
|
||||
import cx from "classnames";
|
||||
import { Show } from "solid-js";
|
||||
import { Match, Show, Switch } from "solid-js";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
|
||||
export type MachineStatus =
|
||||
| "Online"
|
||||
| "Offline"
|
||||
| "Installed"
|
||||
| "Not Installed";
|
||||
import { MachineStatus as MachineStatusModel } from "@/src/hooks/queries";
|
||||
import { Loader } from "../Loader/Loader";
|
||||
|
||||
export interface MachineStatusProps {
|
||||
label?: boolean;
|
||||
status: MachineStatus;
|
||||
status?: MachineStatusModel;
|
||||
}
|
||||
|
||||
export const MachineStatus = (props: MachineStatusProps) => (
|
||||
<Badge
|
||||
class={cx("machine-status", {
|
||||
online: props.status == "Online",
|
||||
offline: props.status == "Offline",
|
||||
installed: props.status == "Installed",
|
||||
"not-installed": props.status == "Not Installed",
|
||||
})}
|
||||
textValue={props.status}
|
||||
>
|
||||
{props.label && (
|
||||
<Typography hierarchy="label" size="xs" weight="medium" inverted={true}>
|
||||
{props.status}
|
||||
</Typography>
|
||||
)}
|
||||
<Show
|
||||
when={props.status == "Not Installed"}
|
||||
fallback={<div class="indicator" />}
|
||||
>
|
||||
<Icon icon="Offline" inverted={true} />
|
||||
</Show>
|
||||
</Badge>
|
||||
);
|
||||
export const MachineStatus = (props: MachineStatusProps) => {
|
||||
const status = () => props.status;
|
||||
|
||||
// remove the '_' from the enum
|
||||
// we will use css transform in the typography component to capitalize
|
||||
const statusText = () => props.status?.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!status()}>
|
||||
<Loader />
|
||||
</Match>
|
||||
<Match when={status()}>
|
||||
<Badge
|
||||
class={cx("machine-status", {
|
||||
online: status() == "online",
|
||||
offline: status() == "offline",
|
||||
"out-of-sync": status() == "out_of_sync",
|
||||
"not-installed": status() == "not_installed",
|
||||
})}
|
||||
textValue={status()}
|
||||
>
|
||||
{props.label && (
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={true}
|
||||
transform="capitalize"
|
||||
>
|
||||
{statusText()}
|
||||
</Typography>
|
||||
)}
|
||||
<Show
|
||||
when={status() != "not_installed"}
|
||||
fallback={<Icon icon="Offline" inverted={true} />}
|
||||
>
|
||||
<div class="indicator" />
|
||||
</Show>
|
||||
</Badge>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,3 +35,12 @@
|
||||
.header_divider {
|
||||
@apply bg-def-3 h-[6px] border-def-2 border-t-[1px];
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
@apply absolute left-0 top-0 z-50 flex size-full items-center justify-center;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TagProps } from "@/src/components/Tag/Tag";
|
||||
import { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { fn } from "storybook/test";
|
||||
import { Modal, ModalContext, ModalProps } from "@/src/components/Modal/Modal";
|
||||
import { Modal, ModalProps } from "@/src/components/Modal/Modal";
|
||||
import { Fieldset, FieldsetFieldProps } from "@/src/components/Form/Fieldset";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: "Example Modal",
|
||||
onClose: fn(),
|
||||
children: ({ close }: ModalContext) => (
|
||||
children: (
|
||||
<form class="flex flex-col gap-5">
|
||||
<Fieldset legend="General">
|
||||
{(props: FieldsetFieldProps) => (
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, createSignal, JSX, Show } from "solid-js";
|
||||
import {
|
||||
Component,
|
||||
JSX,
|
||||
Show,
|
||||
createContext,
|
||||
createSignal,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { Dialog as KDialog } from "@kobalte/core/dialog";
|
||||
import styles from "./Modal.module.css";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
@@ -6,66 +13,81 @@ import Icon from "../Icon/Icon";
|
||||
import cx from "classnames";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
|
||||
export interface ModalContext {
|
||||
close(): void;
|
||||
}
|
||||
export type ModalContextType = {
|
||||
portalRef: HTMLDivElement;
|
||||
};
|
||||
|
||||
const ModalContext = createContext<unknown>();
|
||||
|
||||
export const useModalContext = () => {
|
||||
const context = useContext(ModalContext);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return context as ModalContextType;
|
||||
};
|
||||
|
||||
export interface ModalProps {
|
||||
id?: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: (ctx: ModalContext) => JSX.Element;
|
||||
children: JSX.Element;
|
||||
mount?: Node;
|
||||
class?: string;
|
||||
metaHeader?: Component;
|
||||
disablePadding?: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export const Modal = (props: ModalProps) => {
|
||||
const [open, setOpen] = createSignal(true);
|
||||
|
||||
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
|
||||
return (
|
||||
<KDialog id={props.id} open={open()} modal={true}>
|
||||
<KDialog.Portal mount={props.mount}>
|
||||
<KDialog.Content class={cx(styles.modal_content, props.class)}>
|
||||
<div class={styles.modal_header}>
|
||||
<Typography
|
||||
class={styles.modal_title}
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<KDialog.CloseButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
<Icon icon="Close" size="0.75rem" />
|
||||
</KDialog.CloseButton>
|
||||
<Show when={props.open}>
|
||||
<KDialog id={props.id} open={props.open} modal={true}>
|
||||
<KDialog.Portal mount={props.mount}>
|
||||
<div class={styles.backdrop} />
|
||||
<div class={styles.contentWrapper}>
|
||||
<KDialog.Content class={cx(styles.modal_content, props.class)}>
|
||||
<div class={styles.modal_header}>
|
||||
<Typography
|
||||
class={styles.modal_title}
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<KDialog.CloseButton
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
<Icon icon="Close" size="0.75rem" />
|
||||
</KDialog.CloseButton>
|
||||
</div>
|
||||
<Show when={props.metaHeader}>
|
||||
{(metaHeader) => (
|
||||
<>
|
||||
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
|
||||
<Dynamic component={metaHeader()} />
|
||||
</div>
|
||||
<div class={styles.header_divider} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class={styles.modal_body}
|
||||
data-no-padding={props.disablePadding}
|
||||
ref={setPortalRef}
|
||||
>
|
||||
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
|
||||
{props.children}
|
||||
</ModalContext.Provider>
|
||||
</div>
|
||||
</KDialog.Content>
|
||||
</div>
|
||||
<Show when={props.metaHeader}>
|
||||
{(metaHeader) => (
|
||||
<>
|
||||
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
|
||||
<Dynamic component={metaHeader()} />
|
||||
</div>
|
||||
<div class={styles.header_divider} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div class={styles.modal_body} data-no-padding={props.disablePadding}>
|
||||
{props.children({
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
props.onClose();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</KDialog.Content>
|
||||
</KDialog.Portal>
|
||||
</KDialog>
|
||||
</KDialog.Portal>
|
||||
</KDialog>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,11 +60,11 @@
|
||||
|
||||
/* Option elements (typically <li>) */
|
||||
& [role="option"] {
|
||||
@apply px-2 py-4 rounded-sm flex items-center gap-1 flex-shrink-0;
|
||||
@apply w-full p-2 rounded-sm flex items-center gap-1 flex-shrink-0;
|
||||
|
||||
&[data-highlighted],
|
||||
&:focus-visible {
|
||||
@apply outline outline-1 outline-inv-2;
|
||||
@apply outline outline-1 outline-inv-2 outline-offset-[-1px];
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -77,6 +77,10 @@
|
||||
}
|
||||
|
||||
& [role="listbox"] {
|
||||
width: var(--kb-popper-anchor-width);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
|
||||
&:focus-visible {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ export const Default: Story = {
|
||||
description: "Choose your favorite pet from the list",
|
||||
},
|
||||
options: [
|
||||
{ value: "dog", label: "Doggy" },
|
||||
{
|
||||
value: "dog",
|
||||
label: "DoggyDoggyDoggyDoggyDoggyDoggy DoggyDoggyDoggyDoggyDoggy",
|
||||
},
|
||||
{ value: "cat", label: "Catty" },
|
||||
{ value: "fish", label: "Fishy" },
|
||||
{ value: "bird", label: "Birdy" },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js";
|
||||
import styles from "./Select.module.css";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import cx from "classnames";
|
||||
import { useModalContext } from "../Modal/Modal";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
@@ -17,6 +18,7 @@ export type SelectProps = {
|
||||
// Kobalte Select props, for modular forms
|
||||
name: string;
|
||||
placeholder?: string | undefined;
|
||||
noOptionsText?: string | undefined;
|
||||
value: string | undefined;
|
||||
error: string;
|
||||
required?: boolean | undefined;
|
||||
@@ -79,6 +81,13 @@ export const Select = (props: SelectProps) => {
|
||||
setValue(options().find((option) => props.value === option.value));
|
||||
});
|
||||
|
||||
const modalContext = useModalContext();
|
||||
const defaultMount =
|
||||
props.portalProps?.mount || modalContext?.portalRef || document.body;
|
||||
|
||||
createEffect(() => {
|
||||
console.debug("Select component mounted at:", defaultMount);
|
||||
});
|
||||
return (
|
||||
<KSelect
|
||||
{...root}
|
||||
@@ -100,9 +109,10 @@ export const Select = (props: SelectProps) => {
|
||||
</KSelect.ItemIndicator>
|
||||
<KSelect.ItemLabel>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
hierarchy="label"
|
||||
size="s"
|
||||
weight="bold"
|
||||
family="condensed"
|
||||
class="flex w-full items-center"
|
||||
>
|
||||
{props.item.rawValue.label}
|
||||
@@ -115,9 +125,10 @@ export const Select = (props: SelectProps) => {
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
hierarchy="label"
|
||||
size="s"
|
||||
weight="bold"
|
||||
family="condensed"
|
||||
class="flex w-full items-center"
|
||||
color="secondary"
|
||||
>
|
||||
@@ -125,14 +136,33 @@ export const Select = (props: SelectProps) => {
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
class="flex w-full items-center"
|
||||
<Show
|
||||
when={options().length > 0}
|
||||
fallback={
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="s"
|
||||
weight="normal"
|
||||
family="condensed"
|
||||
class="flex w-full items-center"
|
||||
color="secondary"
|
||||
>
|
||||
{props.noOptionsText || "No options available"}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{props.placeholder}
|
||||
</Typography>
|
||||
<Show when={props.placeholder}>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="s"
|
||||
weight="bold"
|
||||
family="condensed"
|
||||
class="flex w-full items-center"
|
||||
>
|
||||
{props.placeholder}
|
||||
</Typography>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -152,9 +182,10 @@ export const Select = (props: SelectProps) => {
|
||||
<KSelect.Value<Option>>
|
||||
{(state) => (
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
hierarchy="label"
|
||||
size="s"
|
||||
weight="bold"
|
||||
family="condensed"
|
||||
class="flex w-full items-center"
|
||||
>
|
||||
{state.selectedOption().label}
|
||||
@@ -170,12 +201,40 @@ export const Select = (props: SelectProps) => {
|
||||
</KSelect.Icon>
|
||||
</KSelect.Trigger>
|
||||
</Orienter>
|
||||
<KSelect.Portal {...props.portalProps}>
|
||||
<KSelect.Portal mount={defaultMount} {...props.portalProps}>
|
||||
<KSelect.Content
|
||||
class={styles.options_content}
|
||||
style={{ "--z-index": zIndex() }}
|
||||
>
|
||||
<KSelect.Listbox />
|
||||
<KSelect.Listbox>
|
||||
{() => (
|
||||
<KSelect.Trigger
|
||||
class={cx(styles.trigger)}
|
||||
style={{ "--z-index": zIndex() }}
|
||||
data-loading={loading() || undefined}
|
||||
>
|
||||
<KSelect.Value<Option>>
|
||||
{(state) => (
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
class="flex w-full items-center"
|
||||
>
|
||||
{state.selectedOption().label}
|
||||
</Typography>
|
||||
)}
|
||||
</KSelect.Value>
|
||||
<KSelect.Icon
|
||||
as="button"
|
||||
class={styles.icon}
|
||||
data-loading={loading() || undefined}
|
||||
>
|
||||
<Icon icon="Expand" color="inherit" />
|
||||
</KSelect.Icon>
|
||||
</KSelect.Trigger>
|
||||
)}
|
||||
</KSelect.Listbox>
|
||||
</KSelect.Content>
|
||||
</KSelect.Portal>
|
||||
{/* TODO: Display error next to the problem */}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
div.sidebar {
|
||||
.sidebar {
|
||||
@apply w-60 border-none z-10;
|
||||
|
||||
& > div.header {
|
||||
}
|
||||
|
||||
& > div.body {
|
||||
.body {
|
||||
@apply pt-4 pb-3 px-2;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./Sidebar.css";
|
||||
import styles from "./Sidebar.module.css";
|
||||
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface LinkProps {
|
||||
path: string;
|
||||
@@ -13,16 +14,15 @@ export interface SectionProps {
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
class?: string;
|
||||
staticSections?: SectionProps[];
|
||||
}
|
||||
|
||||
export const Sidebar = (props: SidebarProps) => {
|
||||
return (
|
||||
<>
|
||||
<div class="sidebar">
|
||||
<SidebarHeader />
|
||||
<SidebarBody {...props} />
|
||||
</div>
|
||||
</>
|
||||
<div class={cx(styles.sidebar, props.class)}>
|
||||
<SidebarHeader />
|
||||
<SidebarBody class={cx(styles.body)} {...props} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,13 +9,13 @@ div.sidebar-body {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
scrollbar-color: theme(colors.primary.700) theme(colors.primary.600);
|
||||
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
theme(colors.bg.inv.1) 0%,
|
||||
theme(colors.bg.inv.3) 100%
|
||||
);
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
theme(colors.bg.inv.1) 0%,
|
||||
theme(colors.bg.inv.3) 100%
|
||||
);
|
||||
|
||||
@apply backdrop-blur-sm;
|
||||
|
||||
@@ -27,14 +27,12 @@ div.sidebar-body {
|
||||
}
|
||||
|
||||
& > .item {
|
||||
@apply py-3 px-1.5 bg-inv-3 rounded-md mb-4;
|
||||
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
& > .header {
|
||||
@apply flex mb-4 px-2;
|
||||
@apply flex mb-2 px-2;
|
||||
|
||||
& > .trigger {
|
||||
@apply inline-flex items-center justify-between w-full;
|
||||
@@ -61,6 +59,8 @@ div.sidebar-body {
|
||||
|
||||
& > .content {
|
||||
@apply overflow-hidden flex flex-col;
|
||||
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
|
||||
|
||||
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
|
||||
&[data-expanded] {
|
||||
|
||||
@@ -6,47 +6,53 @@ import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { For } from "solid-js";
|
||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||
import { useMachinesQuery } from "@/src/hooks/queries";
|
||||
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import { SidebarProps } from "./Sidebar";
|
||||
|
||||
interface MachineProps {
|
||||
clanURI: string;
|
||||
machineID: string;
|
||||
name: string;
|
||||
status: MachineStatus;
|
||||
serviceCount: number;
|
||||
}
|
||||
|
||||
const MachineRoute = (props: MachineProps) => (
|
||||
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{props.name}
|
||||
</Typography>
|
||||
<MachineStatus status={props.status} />
|
||||
const MachineRoute = (props: MachineProps) => {
|
||||
const statusQuery = useMachineStateQuery(props.clanURI, props.machineID);
|
||||
|
||||
const status = () =>
|
||||
statusQuery.isSuccess ? statusQuery.data.status : undefined;
|
||||
|
||||
return (
|
||||
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{props.name}
|
||||
</Typography>
|
||||
<MachineStatus status={status()} />
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center gap-1">
|
||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="s"
|
||||
inverted={true}
|
||||
color="primary"
|
||||
>
|
||||
{props.serviceCount}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row items-center gap-1">
|
||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="s"
|
||||
inverted={true}
|
||||
color="primary"
|
||||
>
|
||||
{props.serviceCount}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
</A>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarBody = (props: SidebarProps) => {
|
||||
const clanURI = useClanURI();
|
||||
@@ -96,7 +102,6 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
clanURI={clanURI}
|
||||
machineID={id}
|
||||
name={machine.name || id}
|
||||
status="Not Installed"
|
||||
serviceCount={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.machineStatus {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
|
||||
.summary {
|
||||
@apply flex flex-row justify-between items-center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import styles from "./SidebarMachineStatus.module.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||
|
||||
export interface SidebarMachineStatusProps {
|
||||
class?: string;
|
||||
clanURI: string;
|
||||
machineName: string;
|
||||
}
|
||||
|
||||
export const SidebarMachineStatus = (props: SidebarMachineStatusProps) => {
|
||||
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||
|
||||
return (
|
||||
<div class={styles.machineStatus}>
|
||||
<div class={styles.summary}>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={true}
|
||||
color="tertiary"
|
||||
>
|
||||
Status
|
||||
</Typography>
|
||||
<MachineStatus
|
||||
label
|
||||
status={query.isSuccess ? query.data.status : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ div.sidebar-pane {
|
||||
animation: sidebarPaneHide 250ms ease-out 300ms forwards;
|
||||
|
||||
& > div.header > *,
|
||||
& > div.sub-header > *,
|
||||
& > div.body > * {
|
||||
animation: sidebarFadeOut 250ms ease-out forwards;
|
||||
}
|
||||
@@ -35,6 +36,25 @@ div.sidebar-pane {
|
||||
}
|
||||
}
|
||||
|
||||
& > div.sub-header {
|
||||
@apply px-3 py-1;
|
||||
@apply border-b-[1px] border-b-bg-inv-4;
|
||||
@apply border-r-[1px] border-r-bg-inv-3 border-l-[1px] border-l-bg-inv-3;
|
||||
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.08) 100%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
theme(colors.bg.inv.3) 0%,
|
||||
theme(colors.bg.inv.4) 100%
|
||||
);
|
||||
|
||||
& > * {
|
||||
@apply opacity-0;
|
||||
animation: sidebarFadeIn 250ms ease-in 250ms forwards;
|
||||
}
|
||||
}
|
||||
|
||||
& > div.body {
|
||||
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
|
||||
@apply backdrop-blur-md;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, JSX, onMount } from "solid-js";
|
||||
import { createSignal, JSX, onMount, Show } from "solid-js";
|
||||
import "./SidebarPane.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import Icon from "../Icon/Icon";
|
||||
@@ -6,8 +6,10 @@ import { Button as KButton } from "@kobalte/core/button";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface SidebarPaneProps {
|
||||
class?: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
subHeader?: () => JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
@@ -26,7 +28,12 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={cx("sidebar-pane", { closing: closing(), open: open() })}>
|
||||
<div
|
||||
class={cx("sidebar-pane", props.class, {
|
||||
closing: closing(),
|
||||
open: open(),
|
||||
})}
|
||||
>
|
||||
<div class="header">
|
||||
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
|
||||
{props.title}
|
||||
@@ -35,6 +42,9 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
||||
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
|
||||
</KButton>
|
||||
</div>
|
||||
<Show when={props.subHeader}>
|
||||
<div class="sub-header">{props.subHeader!()}</div>
|
||||
</Show>
|
||||
<div class="body">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.install {
|
||||
@apply flex flex-col gap-4 w-full justify-center items-center;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { InstallModal } from "@/src/workflows/Install/install";
|
||||
import { useMachineName } from "@/src/hooks/clan";
|
||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import styles from "./SidebarSectionInstall.module.css";
|
||||
import { Alert } from "../Alert/Alert";
|
||||
|
||||
export interface SidebarSectionInstallProps {
|
||||
clanURI: string;
|
||||
machineName: string;
|
||||
}
|
||||
|
||||
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
|
||||
const query = useMachineStateQuery(props.clanURI, props.machineName);
|
||||
|
||||
const [showInstall, setShowModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
<Show when={query.isSuccess && query.data.status == "not_installed"}>
|
||||
<div class={styles.install}>
|
||||
<Alert
|
||||
type="warning"
|
||||
size="s"
|
||||
title="Your machine is not installed yet"
|
||||
description="Start the process by clicking the button below."
|
||||
></Alert>
|
||||
<Button hierarchy="primary" size="s" onClick={() => setShowModal(true)}>
|
||||
Install machine
|
||||
</Button>
|
||||
<Show when={showInstall()}>
|
||||
<InstallModal
|
||||
open={showInstall()}
|
||||
machineName={useMachineName()}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
span.tag {
|
||||
@apply flex items-center gap-1 w-fit px-2 py-1 rounded-full;
|
||||
@apply flex items-center gap-1 w-fit px-2 py-[0.1875rem] rounded-full;
|
||||
@apply bg-def-4;
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -28,25 +28,25 @@
|
||||
&.size-default {
|
||||
font-size: 1rem;
|
||||
line-height: 1.32;
|
||||
letter-spacing: 0.02rem;
|
||||
letter-spacing: 0.005rem;
|
||||
}
|
||||
|
||||
&.size-s {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.32;
|
||||
letter-spacing: 0.0175rem;
|
||||
letter-spacing: 0.00875rem;
|
||||
}
|
||||
|
||||
&.size-xs {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.32;
|
||||
letter-spacing: 0.0225rem;
|
||||
letter-spacing: 0.01625rem;
|
||||
}
|
||||
|
||||
&.size-xxs {
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.32;
|
||||
letter-spacing: 0.00688rem;
|
||||
letter-spacing: 0.015rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,27 +55,21 @@
|
||||
font-family: "Archivo SemiCondensed", sans-serif;
|
||||
|
||||
&.size-default {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.0175rem;
|
||||
font-size: 1rem;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.02rem;
|
||||
}
|
||||
|
||||
&.size-s {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
font-size: 0.875rem;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.0175rem;
|
||||
}
|
||||
|
||||
&.size-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.0075rem;
|
||||
}
|
||||
|
||||
&.size-xxs {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
font-size: 0.8125rem;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.008125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,20 +77,20 @@
|
||||
font-family: "Commit Mono", monospace;
|
||||
|
||||
&.size-default {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&.size-s {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
font-size: 0.875rem;
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&.size-xs {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
font-size: 0.8125rem;
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { API } from "@/api/API";
|
||||
import { Schema as Inventory } from "@/api/Inventory";
|
||||
|
||||
export type OperationNames = keyof API;
|
||||
type Services = NonNullable<Inventory["services"]>;
|
||||
type ServiceNames = keyof Services;
|
||||
|
||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||
export type OperationResponse<T extends OperationNames> = API[T]["return"];
|
||||
|
||||
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
|
||||
Services[T]
|
||||
>[string];
|
||||
|
||||
export type SuccessQuery<T extends OperationNames> = Extract<
|
||||
OperationResponse<T>,
|
||||
{ status: "success" }
|
||||
|
||||
@@ -8,6 +8,10 @@ export type ClanDetailsWithURI = ClanDetails & { uri: string };
|
||||
|
||||
export type Tags = SuccessData<"list_tags">;
|
||||
export type Machine = SuccessData<"get_machine">;
|
||||
|
||||
export type MachineState = SuccessData<"get_machine_state">;
|
||||
export type MachineStatus = MachineState["status"];
|
||||
|
||||
export type ListMachines = SuccessData<"list_machines">;
|
||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||
|
||||
@@ -22,6 +26,11 @@ export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
|
||||
|
||||
export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
if (!clanURI) {
|
||||
throw new Error("useMachinesQuery: clanURI is undefined");
|
||||
}
|
||||
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
@@ -94,6 +103,33 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
refetchInterval: 1000 * 60, // poll every 60 seconds
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await apiCall.result;
|
||||
if (result.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching machine status: " + result.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useMachineDetailsQuery = (
|
||||
clanURI: string,
|
||||
machineName: string,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.create-backdrop {
|
||||
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.create-modal {
|
||||
@apply min-w-96;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
}
|
||||
|
||||
div.sidebar {
|
||||
@apply absolute top-10 bottom-20 left-4 w-60;
|
||||
}
|
||||
|
||||
div.sidebar-pane {
|
||||
@apply absolute top-12 bottom-20 left-[16.5rem] w-64;
|
||||
}
|
||||
15
pkgs/clan-app/ui/src/routes/Clan/Clan.module.css
Normal file
15
pkgs/clan-app/ui/src/routes/Clan/Clan.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.fadeOut {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.createModal {
|
||||
@apply min-w-96;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply absolute left-4 top-10 w-60;
|
||||
@apply min-h-96;
|
||||
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
useMachinesQuery,
|
||||
} from "@/src/hooks/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { store, setStore, clanURIs } from "@/src/stores/clan";
|
||||
import { store, setStore, clanURIs, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Splash } from "@/src/scene/splash";
|
||||
import cx from "classnames";
|
||||
import "./Clan.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";
|
||||
@@ -37,7 +37,7 @@ import { useNavigate } from "@solidjs/router";
|
||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<Sidebar class={cx(styles.sidebar)} />
|
||||
{props.children}
|
||||
<ClanSceneController {...props} />
|
||||
</>
|
||||
@@ -54,54 +54,43 @@ interface MockProps {
|
||||
}
|
||||
|
||||
const MockCreateMachine = (props: MockProps) => {
|
||||
let container: Node;
|
||||
|
||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
||||
|
||||
return (
|
||||
<div ref={(el) => (container = el)} class="create-backdrop">
|
||||
<Modal
|
||||
mount={container!}
|
||||
onClose={() => {
|
||||
reset(form);
|
||||
props.onClose();
|
||||
}}
|
||||
class="create-modal"
|
||||
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
|
||||
<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"
|
||||
type="submit"
|
||||
hierarchy="primary"
|
||||
onClick={close}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,6 +98,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const clanURI = useClanURI();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
|
||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
@@ -140,6 +131,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
// Important: rejects the promise
|
||||
throw new Error(res.errors[0].message);
|
||||
}
|
||||
|
||||
// trigger a refetch of the machines query
|
||||
machinesQuery.refetch();
|
||||
|
||||
return { id: values.name };
|
||||
};
|
||||
|
||||
@@ -164,6 +159,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const machine = createMemo(() => maybeUseMachineName());
|
||||
|
||||
createEffect(() => {
|
||||
console.log("Selected clan:", clanURI);
|
||||
});
|
||||
|
||||
createEffect(
|
||||
on(machine, (machineId) => {
|
||||
if (machineId) {
|
||||
@@ -220,9 +219,16 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Button
|
||||
onClick={() => setActiveClanURI(undefined)}
|
||||
hierarchy="primary"
|
||||
class="absolute bottom-4 right-4"
|
||||
>
|
||||
close this clan
|
||||
</Button>
|
||||
<div
|
||||
class={cx({
|
||||
"fade-out": !machinesQuery.isLoading && loadingCooldown(),
|
||||
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(),
|
||||
})}
|
||||
>
|
||||
<Splash />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "solid-js";
|
||||
import { Component, createEffect, on } from "solid-js";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { activeClanURI } from "@/src/stores/clan";
|
||||
import { navigateToClan } from "@/src/hooks/clan";
|
||||
@@ -6,13 +6,17 @@ import { navigateToClan } from "@/src/hooks/clan";
|
||||
export const Layout: Component<RouteSectionProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// check for an active clan uri and redirect to it on first load
|
||||
const activeURI = activeClanURI();
|
||||
if (!props.location.pathname.startsWith("/clans/") && activeURI) {
|
||||
navigateToClan(navigate, activeURI);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
// check for an active clan uri and redirect if no clan is active
|
||||
createEffect(
|
||||
on(activeClanURI, (activeURI) => {
|
||||
console.debug("Active Clan URI changed:", activeURI);
|
||||
if (activeURI && !props.location.pathname.startsWith("/clans/")) {
|
||||
navigateToClan(navigate, activeURI);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return <div class="size-full h-screen">{props.children}</div>;
|
||||
};
|
||||
|
||||
6
pkgs/clan-app/ui/src/routes/Machine/Machine.module.css
Normal file
6
pkgs/clan-app/ui/src/routes/Machine/Machine.module.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.sidebarPane {
|
||||
@apply absolute left-[16.5rem] top-12 w-64;
|
||||
@apply min-h-96;
|
||||
|
||||
height: calc(100vh - 10rem);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
||||
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { SectionGeneral } from "./SectionGeneral";
|
||||
import { InstallModal } from "@/src/workflows/Install/install";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
|
||||
import { SectionTags } from "@/src/routes/Machine/SectionTags";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||
|
||||
import cx from "classnames";
|
||||
import styles from "./Machine.module.css";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -18,10 +21,6 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
navigateToClan(navigate, clanURI);
|
||||
};
|
||||
|
||||
const [showInstall, setShowModal] = createSignal(false);
|
||||
|
||||
let container: Node;
|
||||
|
||||
const sidebarPane = (machineName: string) => {
|
||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||
|
||||
@@ -53,7 +52,15 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||
|
||||
return (
|
||||
<SidebarPane title={machineName} onClose={onClose}>
|
||||
<SidebarPane
|
||||
class={cx(styles.sidebarPane)}
|
||||
title={machineName}
|
||||
onClose={onClose}
|
||||
subHeader={() => (
|
||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||
)}
|
||||
>
|
||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</SidebarPane>
|
||||
@@ -62,25 +69,6 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
|
||||
return (
|
||||
<Show when={useMachineName()} keyed>
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
class="absolute right-0 top-0 m-4"
|
||||
>
|
||||
Install me!
|
||||
</Button>
|
||||
<Show when={showInstall()}>
|
||||
<div
|
||||
class="absolute left-0 top-0 z-50 flex size-full items-center justify-center bg-white/90"
|
||||
ref={(el) => (container = el)}
|
||||
>
|
||||
<InstallModal
|
||||
machineName={useMachineName()}
|
||||
mount={container!}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
{sidebarPane(useMachineName())}
|
||||
</Show>
|
||||
);
|
||||
|
||||
@@ -62,6 +62,11 @@ class RenderLoop {
|
||||
|
||||
this.renderRequested = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.initialized) {
|
||||
console.log("RenderLoop not initialized, skipping render.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateTweens();
|
||||
|
||||
const needsUpdate = this.controls.update(); // returns true if damping is ongoing
|
||||
@@ -69,6 +74,7 @@ class RenderLoop {
|
||||
this.render();
|
||||
this.renderRequested = false;
|
||||
|
||||
// Controls smoothing may require another render
|
||||
if (needsUpdate) {
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
@apply absolute bottom-8 z-10 w-full;
|
||||
@apply absolute bottom-10 z-10 w-full;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ const activeClanURI = () => store.activeClanURI;
|
||||
*
|
||||
* @param {string} uri - The URI to be set as the active Clan URI.
|
||||
*/
|
||||
const setActiveClanURI = (uri: string) => setStore("activeClanURI", uri);
|
||||
const setActiveClanURI = (uri: string | undefined) =>
|
||||
setStore("activeClanURI", uri);
|
||||
|
||||
/**
|
||||
* Retrieves the current list of clan URIs from the store.
|
||||
|
||||
@@ -30,12 +30,14 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
{
|
||||
name: "sda_bla_bla",
|
||||
path: "/dev/sda",
|
||||
id_link: "sda_bla_bla",
|
||||
id_link: "usb-bla-bla",
|
||||
size: "12gb",
|
||||
},
|
||||
{
|
||||
name: "sdb_foo_foo",
|
||||
path: "/dev/sdb",
|
||||
id_link: "sdb_foo_foo",
|
||||
id_link: "usb-boo-foo",
|
||||
size: "16gb",
|
||||
},
|
||||
] as SuccessQuery<"list_system_storage_devices">["data"]["blockdevices"],
|
||||
},
|
||||
@@ -64,17 +66,79 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
label: "Gritty Name",
|
||||
helperText: null,
|
||||
label: "(1) Name",
|
||||
group: "User",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gritty.foo",
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
helperText: null,
|
||||
label: "(2) Password",
|
||||
group: "Root",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gritty.bar",
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
helperText: null,
|
||||
label: "(3) Gritty",
|
||||
group: "Root",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "funny.dodo",
|
||||
prompts: [
|
||||
{
|
||||
name: "gritty.name",
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
helperText: null,
|
||||
label: "(4) Name",
|
||||
group: "User",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gritty.foo",
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
helperText: null,
|
||||
label: "(5) Password",
|
||||
group: "Lonely",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gritty.bar",
|
||||
description: "Name of the gritty",
|
||||
prompt_type: "line",
|
||||
display: {
|
||||
helperText: null,
|
||||
label: "(6) Batty",
|
||||
group: "Root",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
run_generators: null,
|
||||
get_machine_hardware_summary: {
|
||||
hardware_config: "nixos-facter",
|
||||
platform: "x86_64-linux",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -139,6 +203,7 @@ type Story = StoryObj<typeof InstallModal>;
|
||||
export const Init: Story = {
|
||||
description: "Welcome step for the installation workflow",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "init",
|
||||
},
|
||||
@@ -146,6 +211,7 @@ export const Init: Story = {
|
||||
export const CreateInstallerProse: Story = {
|
||||
description: "Prose step for creating an installer",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "create:prose",
|
||||
},
|
||||
@@ -153,6 +219,7 @@ export const CreateInstallerProse: Story = {
|
||||
export const CreateInstallerImage: Story = {
|
||||
description: "Configure the image to install",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "create:image",
|
||||
},
|
||||
@@ -160,6 +227,7 @@ export const CreateInstallerImage: Story = {
|
||||
export const CreateInstallerDisk: Story = {
|
||||
description: "Select a disk to install the image on",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "create:disk",
|
||||
},
|
||||
@@ -167,6 +235,7 @@ export const CreateInstallerDisk: Story = {
|
||||
export const CreateInstallerProgress: Story = {
|
||||
description: "Showed while the USB stick is being flashed",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "create:progress",
|
||||
},
|
||||
@@ -174,6 +243,7 @@ export const CreateInstallerProgress: Story = {
|
||||
export const CreateInstallerDone: Story = {
|
||||
description: "Installation done step",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "create:done",
|
||||
},
|
||||
@@ -181,6 +251,7 @@ export const CreateInstallerDone: Story = {
|
||||
export const InstallConfigureAddress: Story = {
|
||||
description: "Installation configure address step",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:address",
|
||||
},
|
||||
@@ -188,6 +259,7 @@ export const InstallConfigureAddress: Story = {
|
||||
export const InstallCheckHardware: Story = {
|
||||
description: "Installation check hardware step",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:check-hardware",
|
||||
},
|
||||
@@ -195,6 +267,7 @@ export const InstallCheckHardware: Story = {
|
||||
export const InstallSelectDisk: Story = {
|
||||
description: "Select disk to install the system on",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:disk",
|
||||
},
|
||||
@@ -202,6 +275,7 @@ export const InstallSelectDisk: Story = {
|
||||
export const InstallVars: Story = {
|
||||
description: "Fill required credentials and data for the installation",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:data",
|
||||
},
|
||||
@@ -209,6 +283,7 @@ export const InstallVars: Story = {
|
||||
export const InstallSummary: Story = {
|
||||
description: "Summary of the installation steps",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:summary",
|
||||
},
|
||||
@@ -216,6 +291,7 @@ export const InstallSummary: Story = {
|
||||
export const InstallProgress: Story = {
|
||||
description: "Shown while the installation is in progress",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:progress",
|
||||
},
|
||||
@@ -223,6 +299,7 @@ export const InstallProgress: Story = {
|
||||
export const InstallDone: Story = {
|
||||
description: "Shown after the installation is done",
|
||||
args: {
|
||||
open: true,
|
||||
machineName: "Test Machine",
|
||||
initialStep: "install:done",
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface InstallModalProps {
|
||||
initialStep?: InstallSteps[number]["id"];
|
||||
mount?: Node;
|
||||
onClose?: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
@@ -85,18 +86,18 @@ export const InstallModal = (props: InstallModalProps) => {
|
||||
<StepperProvider stepper={stepper}>
|
||||
<Modal
|
||||
class="h-[30rem] w-screen max-w-3xl"
|
||||
mount={props.mount}
|
||||
title="Install machine"
|
||||
onClose={() => {
|
||||
console.log("Install modal closed");
|
||||
props.onClose?.();
|
||||
}}
|
||||
open={props.open}
|
||||
// @ts-expect-error some steps might not have
|
||||
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
||||
// @ts-expect-error some steps might not have
|
||||
disablePadding={stepper.currentStep()?.isSplash}
|
||||
>
|
||||
{(ctx) => <InstallStepper onDone={ctx.close} />}
|
||||
<InstallStepper onDone={() => props.onClose} />
|
||||
</Modal>
|
||||
</StepperProvider>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import Icon from "@/src/components/Icon/Icon";
|
||||
import { useSystemStorageOptions } from "@/src/hooks/queries";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import { onMount } from "solid-js";
|
||||
import cx from "classnames";
|
||||
|
||||
const Prose = () => (
|
||||
<StepLayout
|
||||
@@ -141,18 +142,11 @@ const ConfigureImage = () => {
|
||||
throw new Error("No data returned from api call");
|
||||
};
|
||||
|
||||
let content: Node;
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} class="h-full">
|
||||
<StepLayout
|
||||
body={
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
ref={(el) => {
|
||||
content = el;
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Fieldset>
|
||||
<Field name="ssh_key">
|
||||
{(field, input) => (
|
||||
@@ -195,6 +189,8 @@ const ChooseDiskSchema = v.object({
|
||||
|
||||
type ChooseDiskForm = v.InferInput<typeof ChooseDiskSchema>;
|
||||
|
||||
const installMediaRegex = new RegExp("^(?<type>usb|mmc)-(?<name>.*)(-(.*))?$");
|
||||
|
||||
const ChooseDisk = () => {
|
||||
const stepSignal = useStepper<InstallSteps>();
|
||||
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||
@@ -234,7 +230,33 @@ const ChooseDisk = () => {
|
||||
stepSignal.next();
|
||||
};
|
||||
|
||||
const stripId = (s: string) => s.split("-")[1] ?? s;
|
||||
const getOptions = async () => {
|
||||
if (!systemStorageQuery.data) {
|
||||
await systemStorageQuery.refetch();
|
||||
}
|
||||
|
||||
const blockDevices = systemStorageQuery.data?.blockdevices ?? [];
|
||||
|
||||
const options = blockDevices
|
||||
// we only want writeable block devices which are USB or MMC (SD cards)
|
||||
.filter(({ id_link, ro }) => !ro && installMediaRegex.test(id_link))
|
||||
// transform each entry into an option
|
||||
.map(({ id_link, size, path }) => {
|
||||
const match = id_link.match(installMediaRegex)!;
|
||||
const name = match.groups?.name || "";
|
||||
|
||||
const truncatedName =
|
||||
name.length > 32 ? name.slice(0, 32) + "..." : name;
|
||||
|
||||
return {
|
||||
value: path,
|
||||
label: `${truncatedName.replaceAll("_", " ")} (${size})`,
|
||||
};
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} class="h-full">
|
||||
<StepLayout
|
||||
@@ -250,28 +272,21 @@ const ChooseDisk = () => {
|
||||
error={field.error}
|
||||
required
|
||||
label={{
|
||||
label: "USB Stick",
|
||||
description: "Select the usb stick",
|
||||
}}
|
||||
getOptions={async () => {
|
||||
if (!systemStorageQuery.data) {
|
||||
await systemStorageQuery.refetch();
|
||||
}
|
||||
console.log(systemStorageQuery.data);
|
||||
|
||||
return (systemStorageQuery.data?.blockdevices ?? []).map(
|
||||
(dev) => ({
|
||||
value: dev.path,
|
||||
label: stripId(dev.id_link),
|
||||
}),
|
||||
);
|
||||
label: "Install Media",
|
||||
description:
|
||||
"Select a USB stick or SD card from the list",
|
||||
}}
|
||||
getOptions={getOptions}
|
||||
placeholder="Choose Device"
|
||||
noOptionsText="No devices found"
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Alert
|
||||
transparent
|
||||
dense
|
||||
size="s"
|
||||
type="error"
|
||||
icon="Info"
|
||||
title="You're about to format this drive"
|
||||
@@ -283,7 +298,7 @@ const ChooseDisk = () => {
|
||||
footer={
|
||||
<div class="flex justify-between">
|
||||
<BackButton />
|
||||
<NextButton endIcon="Flash">Flash USB Stick</NextButton>
|
||||
<NextButton endIcon="Flash">Flash Installer</NextButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -314,8 +329,17 @@ const FlashProgress = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
|
||||
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||
<div
|
||||
class={cx(
|
||||
"relative flex size-full flex-col items-center justify-center bg-inv-4",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src="/logos/usb-stick-min.png"
|
||||
alt="usb logo"
|
||||
class="absolute top-2 z-0"
|
||||
/>
|
||||
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||
<Typography
|
||||
hierarchy="title"
|
||||
size="default"
|
||||
@@ -327,7 +351,7 @@ const FlashProgress = () => {
|
||||
<LoadingBar />
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
class="w-fit"
|
||||
class="mt-3 w-fit"
|
||||
size="s"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
|
||||
@@ -60,6 +60,7 @@ const ConfigureAddress = () => {
|
||||
});
|
||||
|
||||
const [isReachable, setIsReachable] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal<boolean>(false);
|
||||
|
||||
const client = useApiClient();
|
||||
// TODO: push values to the parent form Store
|
||||
@@ -80,12 +81,15 @@ const ConfigureAddress = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const call = client.fetch("check_machine_ssh_login", {
|
||||
remote: {
|
||||
address,
|
||||
},
|
||||
});
|
||||
const result = await call.result;
|
||||
setLoading(false);
|
||||
|
||||
console.log("SSH login check result:", result);
|
||||
if (result.status === "success") {
|
||||
setIsReachable(address);
|
||||
@@ -118,28 +122,28 @@ const ConfigureAddress = () => {
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
<Button
|
||||
disabled={!getValue(formStore, "targetHost")}
|
||||
endIcon="ArrowRight"
|
||||
onClick={tryReachable}
|
||||
hierarchy="secondary"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div class="flex justify-between">
|
||||
<BackButton />
|
||||
<NextButton
|
||||
type="submit"
|
||||
disabled={
|
||||
|
||||
<Show
|
||||
when={
|
||||
!isReachable() ||
|
||||
isReachable() !== getValue(formStore, "targetHost")
|
||||
}
|
||||
fallback={<NextButton type="submit">Next</NextButton>}
|
||||
>
|
||||
Next
|
||||
</NextButton>
|
||||
<Button
|
||||
endIcon="ArrowRight"
|
||||
onClick={tryReachable}
|
||||
hierarchy="secondary"
|
||||
loading={loading()}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -208,6 +212,7 @@ const CheckHardware = () => {
|
||||
<Show when={hardwareQuery.data}>
|
||||
{(d) => (
|
||||
<Alert
|
||||
size="s"
|
||||
icon={reportExists() ? "Checkmark" : "Close"}
|
||||
type={reportExists() ? "info" : "warning"}
|
||||
title={
|
||||
@@ -435,7 +440,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} class="h-full">
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<StepLayout
|
||||
body={
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -545,14 +550,22 @@ const InstallSummary = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract generator names from prompt values
|
||||
// TODO: This is wrong. We need to extend run_generators to be able to compute
|
||||
// a sane closure over a list of provided generators.
|
||||
const generators = Object.keys(store.install.promptValues || {});
|
||||
|
||||
const runGenerators = client.fetch("run_generators", {
|
||||
all_prompt_values: store.install.promptValues,
|
||||
machine: {
|
||||
name: store.install.machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
generators: generators.length > 0 ? generators : undefined,
|
||||
prompt_values: store.install.promptValues,
|
||||
machines: [
|
||||
{
|
||||
name: store.install.machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
set("install", (s) => ({
|
||||
@@ -580,7 +593,7 @@ const InstallSummary = () => {
|
||||
progress: runInstall,
|
||||
}));
|
||||
|
||||
await runInstall.result; // Wait for the installation to finish
|
||||
await runInstall.result;
|
||||
|
||||
stepSignal.setActiveStep("install:done");
|
||||
};
|
||||
@@ -645,8 +658,13 @@ const InstallProgress = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
|
||||
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||
<div class="relative flex size-full flex-col items-center justify-center bg-inv-4">
|
||||
<img
|
||||
src="/logos/usb-stick-min.png"
|
||||
alt="usb logo"
|
||||
class="absolute top-2 z-0"
|
||||
/>
|
||||
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
|
||||
<Typography
|
||||
hierarchy="title"
|
||||
size="default"
|
||||
@@ -655,10 +673,11 @@ const InstallProgress = () => {
|
||||
>
|
||||
Machine is beeing installed
|
||||
</Typography>
|
||||
<LoadingBar />
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
class="py-2"
|
||||
class=""
|
||||
color="secondary"
|
||||
inverted
|
||||
>
|
||||
@@ -694,10 +713,9 @@ const InstallProgress = () => {
|
||||
</Match>
|
||||
</Switch>
|
||||
</Typography>
|
||||
<LoadingBar />
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
class="w-fit"
|
||||
class="mt-3 w-fit"
|
||||
size="s"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
|
||||
@@ -203,7 +203,7 @@ const colorSystem = {
|
||||
1: primaries.secondary["950"],
|
||||
2: primaries.secondary["900"],
|
||||
3: primaries.secondary["700"],
|
||||
4: primaries.secondary["500"],
|
||||
4: primaries.secondary["600"],
|
||||
},
|
||||
inv: {
|
||||
1: primaries.off.white,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }],
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import solidSvg from "vite-plugin-solid-svg";
|
||||
import { patchCssModules } from "vite-css-modules";
|
||||
import path from "node:path";
|
||||
import { exec } from "child_process";
|
||||
|
||||
@@ -40,6 +41,7 @@ export default defineConfig({
|
||||
solidPlugin(),
|
||||
solidSvg(),
|
||||
regenPythonApiOnFileChange(),
|
||||
patchCssModules({ generateSourceTypes: true }),
|
||||
],
|
||||
server: {
|
||||
port: 3000,
|
||||
|
||||
@@ -32,16 +32,12 @@ You can also run a single test like this:
|
||||
pytest -n0 -s tests/test_secrets_cli.py::test_users
|
||||
```
|
||||
|
||||
## Run tests in nix container
|
||||
|
||||
Run all impure checks
|
||||
Run all checks in a sandbox
|
||||
|
||||
```bash
|
||||
nix run .#impure-checks
|
||||
nix build .#checks.x86_64-linux.clan-pytest-with-core
|
||||
```
|
||||
|
||||
Run all checks
|
||||
|
||||
```bash
|
||||
nix flake check
|
||||
nix build .#checks.x86_64-linux.clan-pytest-without-core
|
||||
```
|
||||
|
||||
@@ -11,5 +11,4 @@ pytest_plugins = [
|
||||
"clan_cli.tests.runtime",
|
||||
"clan_cli.tests.fixtures_flakes",
|
||||
"clan_cli.tests.stdout",
|
||||
"clan_cli.tests.nix_config",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import argparse
|
||||
import logging
|
||||
|
||||
from clan_lib.flake import require_flake
|
||||
from clan_lib.machines.actions import list_machines
|
||||
from clan_lib.machines.actions import ListOptions, MachineFilter, list_machines
|
||||
|
||||
from clan_cli.completions import add_dynamic_completer, complete_tags
|
||||
|
||||
@@ -12,7 +12,9 @@ log = logging.getLogger(__name__)
|
||||
def list_command(args: argparse.Namespace) -> None:
|
||||
flake = require_flake(args.flake)
|
||||
|
||||
for name in list_machines(flake, opts={"filter": {"tags": args.tags}}):
|
||||
for name in list_machines(
|
||||
flake, opts=ListOptions(filter=MachineFilter(tags=args.tags))
|
||||
):
|
||||
print(name)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime
|
||||
from clan_lib.errors import ClanError
|
||||
from clan_lib.flake import require_flake
|
||||
from clan_lib.flake.flake import Flake
|
||||
from clan_lib.machines.actions import list_machines
|
||||
from clan_lib.machines.actions import ListOptions, MachineFilter, list_machines
|
||||
from clan_lib.machines.list import instantiate_inventory_to_machines
|
||||
from clan_lib.machines.machines import Machine
|
||||
from clan_lib.machines.suggestions import validate_machine_names
|
||||
@@ -49,7 +49,9 @@ def get_machines_for_update(
|
||||
filter_tags: list[str],
|
||||
) -> list[Machine]:
|
||||
all_machines = list_machines(flake)
|
||||
machines_with_tags = list_machines(flake, {"filter": {"tags": filter_tags}})
|
||||
machines_with_tags = list_machines(
|
||||
flake, ListOptions(filter=MachineFilter(tags=filter_tags))
|
||||
)
|
||||
|
||||
if filter_tags and not machines_with_tags:
|
||||
msg = f"No machines found with tags: {' AND '.join(filter_tags)}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
/nix/store/q012qk78pwldxl3qjy09nwrx9jlamivm-nixpkgs
|
||||
/nix/store/apspgd56g9qy6fca8d44qnhdaiqrdf2c-nixpkgs
|
||||
@@ -1,6 +1,5 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,7 +25,7 @@ class SopsSetup:
|
||||
|
||||
def __init__(self, keys: list[KeyPair]) -> None:
|
||||
self.keys = keys
|
||||
self.user = os.environ.get("USER", "admin")
|
||||
self.user = "admin"
|
||||
|
||||
def init(self, flake_path: Path) -> None:
|
||||
cli.run(
|
||||
|
||||
@@ -422,3 +422,103 @@ def test_flake_with_core(
|
||||
monkeypatch=monkeypatch,
|
||||
inventory_expr=inventory_expr,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writable_clan_core(
|
||||
clan_core: Path,
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
"""
|
||||
Creates a writable copy of clan_core in a temporary directory.
|
||||
If clan_core is a git repo, copies tracked files and uncommitted changes.
|
||||
Removes vars/ and sops/ directories if they exist.
|
||||
"""
|
||||
temp_flake = tmp_path / "clan-core"
|
||||
|
||||
# Check if it's a git repository
|
||||
if (clan_core / ".git").exists():
|
||||
# Create the target directory
|
||||
temp_flake.mkdir(parents=True)
|
||||
|
||||
# Copy all tracked and untracked files (excluding ignored)
|
||||
# Using git ls-files with -z for null-terminated output to handle filenames with spaces
|
||||
sp.run(
|
||||
f"(git ls-files -z; git ls-files -z --others --exclude-standard) | "
|
||||
f"xargs -0 cp --parents -t {temp_flake}/",
|
||||
shell=True,
|
||||
cwd=clan_core,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Copy .git directory to maintain git functionality
|
||||
if (clan_core / ".git").is_dir():
|
||||
shutil.copytree(
|
||||
clan_core / ".git", temp_flake / ".git", ignore_dangling_symlinks=True
|
||||
)
|
||||
else:
|
||||
# It's a git file (for submodules/worktrees)
|
||||
shutil.copy2(clan_core / ".git", temp_flake / ".git")
|
||||
else:
|
||||
# Regular copy if not a git repo
|
||||
shutil.copytree(clan_core, temp_flake, ignore_dangling_symlinks=True)
|
||||
|
||||
# Make writable
|
||||
sp.run(["chmod", "-R", "+w", str(temp_flake)], check=True)
|
||||
|
||||
# Remove vars and sops directories
|
||||
shutil.rmtree(temp_flake / "vars", ignore_errors=True)
|
||||
shutil.rmtree(temp_flake / "sops", ignore_errors=True)
|
||||
|
||||
return temp_flake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vm_test_flake(
|
||||
clan_core: Path,
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
"""
|
||||
Creates a test flake that imports the VM test nixOS modules from clan-core.
|
||||
"""
|
||||
test_flake_dir = tmp_path / "test-flake"
|
||||
test_flake_dir.mkdir(parents=True)
|
||||
|
||||
metadata = sp.run(
|
||||
nix_command(["flake", "metadata", "--json"]),
|
||||
cwd=CLAN_CORE,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
metadata_json = json.loads(metadata)
|
||||
clan_core_url = f"path:{metadata_json['path']}"
|
||||
|
||||
# Read the template and substitute the clan-core path
|
||||
template_path = Path(__file__).parent / "vm_test_flake.nix"
|
||||
template_content = template_path.read_text()
|
||||
|
||||
# Get the current system
|
||||
system_result = sp.run(
|
||||
nix_command(["config", "show", "system"]),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
current_system = system_result.stdout.strip()
|
||||
|
||||
# Substitute the clan-core URL and system
|
||||
flake_content = template_content.replace("__CLAN_CORE__", clan_core_url)
|
||||
flake_content = flake_content.replace("__SYSTEM__", current_system)
|
||||
|
||||
# Write the flake.nix
|
||||
(test_flake_dir / "flake.nix").write_text(flake_content)
|
||||
|
||||
# Lock the flake with --allow-dirty to handle uncommitted changes
|
||||
sp.run(
|
||||
nix_command(["flake", "lock", "--allow-dirty-locks"]),
|
||||
cwd=test_flake_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return test_flake_dir
|
||||
|
||||
131
pkgs/clan-cli/clan_cli/tests/flake-module.nix
Normal file
131
pkgs/clan-cli/clan_cli/tests/flake-module.nix
Normal file
@@ -0,0 +1,131 @@
|
||||
{ self, ... }:
|
||||
{
|
||||
# Define machines that use the nixOS modules
|
||||
clan.machines = {
|
||||
test-vm-persistence-x86_64-linux = {
|
||||
imports = [ self.nixosModules.test-vm-persistence ];
|
||||
nixpkgs.hostPlatform = "x86_64-linux";
|
||||
};
|
||||
test-vm-persistence-aarch64-linux = {
|
||||
imports = [ self.nixosModules.test-vm-persistence ];
|
||||
nixpkgs.hostPlatform = "aarch64-linux";
|
||||
};
|
||||
test-vm-deployment-x86_64-linux = {
|
||||
imports = [ self.nixosModules.test-vm-deployment ];
|
||||
nixpkgs.hostPlatform = "x86_64-linux";
|
||||
};
|
||||
test-vm-deployment-aarch64-linux = {
|
||||
imports = [ self.nixosModules.test-vm-deployment ];
|
||||
nixpkgs.hostPlatform = "aarch64-linux";
|
||||
};
|
||||
};
|
||||
|
||||
flake.nixosModules = {
|
||||
# NixOS module for test_vm_persistence
|
||||
test-vm-persistence =
|
||||
{ config, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.clan-vm-base ];
|
||||
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
|
||||
# Disable services that might cause issues in tests
|
||||
systemd.services.logrotate-checkconf.enable = false;
|
||||
services.getty.autologinUser = "root";
|
||||
|
||||
# Basic networking setup
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
|
||||
# VM-specific settings
|
||||
clan.virtualisation.graphics = false;
|
||||
clan.core.networking.targetHost = "client";
|
||||
|
||||
# State configuration for persistence test
|
||||
clan.core.state.my_state.folders = [
|
||||
"/var/my-state"
|
||||
"/var/user-state"
|
||||
];
|
||||
|
||||
# Initialize users for tests
|
||||
users.users = {
|
||||
root = {
|
||||
initialPassword = "root";
|
||||
};
|
||||
test = {
|
||||
initialPassword = "test";
|
||||
isSystemUser = true;
|
||||
group = "users";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# NixOS module for test_vm_deployment
|
||||
test-vm-deployment =
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.clan-vm-base ];
|
||||
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
|
||||
# Disable services that might cause issues in tests
|
||||
systemd.services.logrotate-checkconf.enable = false;
|
||||
services.getty.autologinUser = "root";
|
||||
|
||||
# Basic networking setup
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
|
||||
# VM-specific settings
|
||||
clan.virtualisation.graphics = false;
|
||||
|
||||
# SSH for deployment tests
|
||||
services.openssh.enable = true;
|
||||
|
||||
# Initialize users for tests
|
||||
users.users = {
|
||||
root = {
|
||||
initialPassword = "root";
|
||||
};
|
||||
};
|
||||
|
||||
# hack to make sure
|
||||
sops.validateSopsFiles = false;
|
||||
sops.secrets."vars/m1_generator/my_secret" = lib.mkDefault {
|
||||
sopsFile = builtins.toFile "fake" "";
|
||||
};
|
||||
|
||||
# Vars generators configuration
|
||||
clan.core.vars.generators = {
|
||||
m1_generator = {
|
||||
files.my_secret = {
|
||||
secret = true;
|
||||
path = "/run/secrets/vars/m1_generator/my_secret";
|
||||
};
|
||||
script = ''
|
||||
echo hello > "$out"/my_secret
|
||||
'';
|
||||
};
|
||||
|
||||
my_shared_generator = {
|
||||
share = true;
|
||||
files = {
|
||||
shared_secret = {
|
||||
secret = true;
|
||||
path = "/run/secrets/vars/my_shared_generator/shared_secret";
|
||||
};
|
||||
no_deploy_secret = {
|
||||
secret = true;
|
||||
deploy = false;
|
||||
path = "/run/secrets/vars/my_shared_generator/no_deploy_secret";
|
||||
};
|
||||
};
|
||||
script = ''
|
||||
echo hello > "$out"/shared_secret
|
||||
echo hello > "$out"/no_deploy_secret
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigItem:
|
||||
aliases: list[str]
|
||||
defaultValue: bool # noqa: N815
|
||||
description: str
|
||||
documentDefault: bool # noqa: N815
|
||||
experimentalFeature: str # noqa: N815
|
||||
value: str | bool | list[str] | dict[str, str]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def nix_config() -> dict[str, ConfigItem]:
|
||||
proc = subprocess.run(
|
||||
["nix", "config", "show", "--json"], check=True, stdout=subprocess.PIPE
|
||||
)
|
||||
data = json.loads(proc.stdout)
|
||||
return {name: ConfigItem(**c) for name, c in data.items()}
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from clan_lib.api import API
|
||||
from clan_lib.api.util import JSchemaTypeError, type_to_dict
|
||||
from clan_lib.api.type_to_jsonschema import JSchemaTypeError, type_to_dict
|
||||
from clan_lib.errors import ClanError
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from clan_cli.tests.fixtures_flakes import FlakeForTest
|
||||
from clan_cli.tests.helpers import cli
|
||||
|
||||
|
||||
@pytest.mark.impure
|
||||
@pytest.mark.with_core
|
||||
def test_backups(
|
||||
test_flake_with_core: FlakeForTest,
|
||||
) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.impure
|
||||
@pytest.mark.with_core
|
||||
def test_flakes_inspect(
|
||||
test_flake_with_core: FlakeForTest, capture_output: CaptureOutput
|
||||
) -> None:
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
from clan_lib.nix_models.clan import Inventory
|
||||
from clan_lib.nix_models.clan import InventoryMachine as Machine
|
||||
from clan_lib.nix_models.clan import InventoryMeta as Meta
|
||||
from clan_lib.nix_models.clan import InventoryService as Service
|
||||
|
||||
|
||||
def test_make_meta_minimal() -> None:
|
||||
# Name is required
|
||||
res = Meta(
|
||||
{
|
||||
"name": "foo",
|
||||
}
|
||||
)
|
||||
|
||||
assert res == {"name": "foo"}
|
||||
|
||||
|
||||
def test_make_inventory_minimal() -> None:
|
||||
# Meta is required
|
||||
res = Inventory(
|
||||
{
|
||||
"meta": Meta(
|
||||
{
|
||||
"name": "foo",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
assert res == {"meta": {"name": "foo"}}
|
||||
|
||||
|
||||
def test_make_machine_minimal() -> None:
|
||||
# Empty is valid
|
||||
res = Machine({})
|
||||
|
||||
assert res == {}
|
||||
|
||||
|
||||
def test_make_service_minimal() -> None:
|
||||
# Empty is valid
|
||||
res = Service({})
|
||||
|
||||
assert res == {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user