Compare commits

...

86 Commits

Author SHA1 Message Date
Johannes Kirschbauer
7c8de49258 buildClan: remove in favor of lib.clan 2025-07-21 20:29:26 +02:00
Mic92
579492f071 Merge pull request 'migration guide: fix moon example' (#4423) from docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4423
2025-07-21 17:11:14 +00:00
Jörg Thalheim
0ed02da28f migration guide: fix moon example 2025-07-21 19:07:47 +02:00
Mic92
4abfbb05a2 Merge pull request 'extend migration guide' (#4421) from docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4421
2025-07-21 16:23:58 +00:00
Jörg Thalheim
6126cccbcc extend migration guide 2025-07-21 18:10:58 +02:00
brianmcgee
9e77d16e6d Merge pull request 'fix(ui): alignment issues with forms' (#4418) from ui/minor-fixes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4418
2025-07-21 12:13:36 +00:00
Brian McGee
53752d4a69 fix(ui): alignment issues with forms 2025-07-21 13:09:53 +01:00
DavHau
38955f763f clan default template: add inputs to specialArgs 2025-07-21 18:39:51 +07:00
brianmcgee
bd97896899 Merge pull request 'fix(ui): remove extra margin in modal title' (#4415) from ui/minor-fixes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4415
2025-07-21 10:22:15 +00:00
Brian McGee
d6efeb3295 fix(ui): remove extra margin in modal title 2025-07-21 11:18:22 +01:00
Luis Hebendanz
e3247d9c36 Merge pull request 'Fix multiple bugs in 'clan networking' command' (#4389) from Qubasa/clan-core:deploy_network into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4389
2025-07-21 07:35:54 +00:00
Qubasa
4055508588 clan-lib: Add object_name to ClassSource and don't override __repr__ from NetworkTechnologyBase instead overwrite it in ClassSource 2025-07-21 14:25:01 +07:00
Qubasa
ff65dfc883 clanServices: change tor service to have "client" and "server" roles instead of just "default"
also improve error message when user forgot to update machine in clan
networking command
2025-07-21 14:25:01 +07:00
Qubasa
1f5ef04a61 clan-lib: Fix network.py missing vars generation and use import_with_source for better trace ability 2025-07-21 12:40:49 +07:00
Qubasa
89f0e90910 clan-lib: Init import_utils to add debug information to dynamically imported modules 2025-07-21 12:40:49 +07:00
Qubasa
137aa71529 clan-lib: Fix is_running of tor.py 2025-07-21 12:40:49 +07:00
Qubasa
4b5273fbc1 clanServices: Fix tor service not exposing SOCKS port 2025-07-21 12:40:49 +07:00
clan-bot
aed48be645 Merge pull request 'Update data-mesher' (#4414) from update-data-mesher into main 2025-07-21 05:16:44 +00:00
gitea-actions[bot]
5fdc9823d1 Update data-mesher 2025-07-21 05:00:49 +00:00
clan-bot
f6284a7ac2 Merge pull request 'Update treefmt-nix' (#4405) from update-treefmt-nix into main 2025-07-20 15:15:54 +00:00
gitea-actions[bot]
72473746ff Update treefmt-nix 2025-07-20 15:01:26 +00:00
hsjobeki
4b36b3e07c Merge pull request 'ui/scene: mock create machine modal for testing' (#4404) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4404
2025-07-19 16:23:56 +00:00
Johannes Kirschbauer
5a63eeed4e ui/scene: mock create machine modal for testing 2025-07-19 18:19:37 +02:00
Johannes Kirschbauer
ac96d67f09 components/modal: fix missing onClose call 2025-07-19 18:19:19 +02:00
Johannes Kirschbauer
d01342aa79 components/modal: add missing properties {mount, class} 2025-07-19 18:18:56 +02:00
Johannes Kirschbauer
2d404254da ui/scene: fix initBase visibility 2025-07-19 18:18:05 +02:00
Johannes Kirschbauer
71b69c1010 ui/scene: add promise based create machine callback" 2025-07-19 18:17:38 +02:00
Johannes Kirschbauer
f155c68efe ui/scene: fix animateToPosition 2025-07-19 18:16:53 +02:00
hsjobeki
e57741b60c Merge pull request 'ui/scene: clean up initBase' (#4403) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4403
2025-07-19 12:51:04 +00:00
Johannes Kirschbauer
c9cacfcf62 ui/scene: fix typing checks 2025-07-19 14:47:23 +02:00
Johannes Kirschbauer
2d937b80b1 ui/scene: clean up initBase 2025-07-19 14:40:32 +02:00
clan-bot
e8b91e63bc Merge pull request 'Update treefmt-nix' (#4402) from update-treefmt-nix into main 2025-07-19 10:17:05 +00:00
gitea-actions[bot]
a9d6fa7712 Update treefmt-nix 2025-07-19 10:01:30 +00:00
hsjobeki
65a23983c2 Merge pull request 'ui/scene: add loading splash screen' (#4400) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4400
2025-07-18 17:42:15 +00:00
Johannes Kirschbauer
c181400267 ui/scene: add loading splash screen 2025-07-18 19:37:06 +02:00
hsjobeki
e8ff0d1ad4 Merge pull request 'ui/render: optimize rendering, requestRenderIfNotRequested' (#4398) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4398
2025-07-18 17:36:44 +00:00
Johannes Kirschbauer
f9f8a947e2 ui/splash: add scene splash screen 2025-07-18 19:36:02 +02:00
Johannes Kirschbauer
c5b0154af7 ui/logos: add darknet-builder logo 2025-07-18 19:35:11 +02:00
brianmcgee
864742f05f Merge pull request 'feat(ui): add creating cube animation' (#4399) from ui/creating-animation into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4399
2025-07-18 16:39:08 +00:00
Brian McGee
38b043f625 feat(ui): add creating cube animation 2025-07-18 17:31:30 +01:00
Johannes Kirschbauer
174e66ef95 ui/render: optimize rendering, requestRenderIfNotRequested 2025-07-18 18:15:30 +02:00
hsjobeki
315049de20 Merge pull request 'ui/controls: replace manual listeners with mapControl' (#4397) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4397
2025-07-18 15:49:36 +00:00
Johannes Kirschbauer
2e577dbd1e ui/controls: replace manual listeners with mapControl 2025-07-18 17:45:53 +02:00
Mic92
a9b457e063 Merge pull request 'clanServices/wifi: handle multiple instances' (#4260) from nim65s/clan-core:multi-wifi into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4260
2025-07-18 15:19:24 +00:00
hsjobeki
4281770ec7 Merge pull request 'ui/scene: hook up api' (#4388) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4388
2025-07-18 15:15:41 +00:00
Johannes Kirschbauer
1bd950fa39 ui/scene: remove all unneded complexity to reduce complexity and improve performance 2025-07-18 17:12:09 +02:00
Johannes Kirschbauer
e37b61240b ui/routing: move scene down clans/:id" 2025-07-18 17:11:32 +02:00
Johannes Kirschbauer
23d2975bb5 ui/store: add methods for sceneData 2025-07-18 17:11:04 +02:00
Johannes Kirschbauer
d441d4c1c1 ui/hooks: add overloaded useClanUri 2025-07-18 17:10:39 +02:00
Mic92
840cb7e2cb Merge pull request 'nginx: drop recommendedZstdSettings' (#4396) from zstd into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4396
2025-07-18 14:23:52 +00:00
Jörg Thalheim
cf232e1002 nginx: drop recommendedZstdSettings
nixpkgs no longer recommends it.
2025-07-18 16:17:36 +02:00
Mic92
7414dc6e7e Merge pull request 'clan-app: fix x86_64-darwin build' (#4395) from darwin-build into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4395
2025-07-18 14:10:26 +00:00
Jörg Thalheim
d97f997349 clan-app: fix x86_64-darwin build 2025-07-18 16:06:12 +02:00
pinpox
0621ae1ca6 Merge pull request 'fix workfow' (#4393) from fix-clan-core-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4393
2025-07-18 13:37:56 +00:00
pinpox
992048e1b2 Fix update-clan-core-for-checks script
create-pr needs to use /bin/sh to work. This PR makes the script posix
compliant, replacing any bash specific features with plain sh
alternatives
2025-07-18 15:33:36 +02:00
Mic92
261cad7674 Merge pull request 'build x86_64-darwin on main every few hours' (#4392) from darwin-ci into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4392
2025-07-18 12:43:17 +00:00
Jörg Thalheim
a012e4b1af build x86_64-darwin on main every few hours 2025-07-18 14:39:07 +02:00
Guilhem Saurel
158b98ee05 clanServices/wifi: fix for multiple instances
Without this, `nix build .#checks.x86_64-linux.wifi` fails with:
```
error: The option `nodes.first.systemd.services.NetworkManager-setup-secrets.serviceConfig.ExecStart' has conflicting definition values:
- In `/nix/store/x0…45-source/clanServices/wifi/default.nix, via option mappedServices."self-@clan/wifi".roles.default.perInstance, via option nixosModule': <derivation wifi-secrets>
- In `/nix/store/x0…45-source/clanServices/wifi/default.nix, via option mappedServices."self-@clan/wifi".roles.default.perInstance, via option nixosModule': <derivation wifi-secrets>
Use `lib.mkForce value` or `lib.mkDefault value` to change the priority on any of these definitions.
```
2025-07-17 23:30:50 +02:00
Guilhem Saurel
14d367e50f clanServices/wifi: update test with a second instance 2025-07-17 23:30:47 +02:00
lassulus
48c575699e Merge pull request 'network module + CLI' (#4344) from networking into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4344
2025-07-17 13:36:53 +00:00
lassulus
60768cc537 Add networking module
This adds a (for now hidden) clan network command that exposes list,
ping, overview subcommands to get informations about configured
networks.
ClanServices can now use the exports to define network specific
information.

This is not the complete feature yet, as we are lacking more tests and
documentation, but merging this now makes it easier to iterate.
2025-07-17 15:23:08 +02:00
Johannes Kirschbauer
c26dff282b ui/queries: init queries folder 2025-07-17 13:49:16 +02:00
hsjobeki
5022f6f26c Merge pull request 'ui/clan: rework routing concept' (#4385) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4385
2025-07-17 11:39:33 +00:00
Johannes Kirschbauer
94b93074bc ui/query: add correct resource path 2025-07-17 13:35:50 +02:00
Johannes Kirschbauer
d962033236 ui/clan: rework routing concept 2025-07-17 10:54:48 +02:00
Johannes Kirschbauer
a548851245 ui/hooks: useMaybeClanUri init hook
Needed for pre-rendering the cube scene with clanURI = null
When it later receives a value scene will get populated without completely re-rendering
2025-07-17 10:51:32 +02:00
Johannes Kirschbauer
b32e61bb6d ui/app: wrap with query client povider to make api cached calls 2025-07-17 10:49:47 +02:00
Johannes Kirschbauer
e731322af3 ui/store: infer type from return arg 2025-07-17 10:49:12 +02:00
hsjobeki
fd21c6b4ee Merge pull request 'buildClan: Add deprecation warning' (#4384) from Qubasa/clan-core:migrate_away_buildClan into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4384
Reviewed-by: hsjobeki <hsjobeki@gmail.com>
2025-07-17 08:44:04 +00:00
Qubasa
5a86862f47 buildClan: Add deprecation warning 2025-07-17 15:32:12 +07:00
Michael Hoang
1d1a2563c3 Merge pull request 'flake: remove unnecessary follows for data-mesher' (#4383) from push-yzqmtrtrkkzt into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4383
2025-07-17 07:37:20 +00:00
Michael Hoang
4bc57980ff flake: remove unnecessary follows for data-mesher 2025-07-17 17:30:36 +10:00
Luis Hebendanz
3afd0c0971 Merge pull request 'inventory: Add missing default value for exports.instances and exports.machines' (#4382) from Qubasa/clan-core:fix_inv_missing_default into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4382
2025-07-17 06:17:37 +00:00
Qubasa
e6a6cb27ec inventory: Add missing default value for exports.instances and exports.machines 2025-07-17 13:10:30 +07:00
clan-bot
dcd78c5d84 Merge pull request 'Update disko' (#4381) from update-disko into main 2025-07-17 05:16:49 +00:00
gitea-actions[bot]
2a1ad66292 Update disko 2025-07-17 05:00:49 +00:00
brianmcgee
5d0d4404b8 Merge pull request 'chore: add a check for background.jpg' (#4380) from chore/stupid-jpg-check into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4380
2025-07-16 16:15:38 +00:00
Brian McGee
7b369c77b5 chore: add a check for background.jpg 2025-07-16 18:11:40 +02:00
hsjobeki
06b70a982b Merge pull request 'UI/cubes: extend cubes scene' (#4375) from scene-progress into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4375
2025-07-16 15:20:27 +00:00
Johannes Kirschbauer
c9b1b0fb94 ui/cubes: align with design 2025-07-16 17:12:09 +02:00
Johannes Kirschbauer
66bdbb0959 ui/cubes: init story 2025-07-16 17:12:09 +02:00
Johannes Kirschbauer
752f030d03 ui/storybook: add all stories 2025-07-16 17:12:09 +02:00
Johannes Kirschbauer
8c7e93c92e UI/cubes: group logic to add more meshed 2025-07-16 17:12:09 +02:00
Johannes Kirschbauer
579885a6e2 cubes: scene extend 2025-07-16 17:12:09 +02:00
brianmcgee
45f7ebc0c9 Merge pull request 'feat: onboarding workflow' (#4379) from ui/onboarding-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4379
2025-07-16 15:10:04 +00:00
Brian McGee
997d675f8c feat: onboarding workflow 2025-07-16 17:04:34 +02:00
70 changed files with 3626 additions and 764 deletions

View File

@@ -0,0 +1,20 @@
name: Build Clan App (Darwin)
on:
schedule:
# Run every 4 hours
- cron: "0 */4 * * *"
workflow_dispatch:
push:
branches:
- main
jobs:
build-clan-app-darwin:
runs-on: nix
steps:
- uses: actions/checkout@v4
- name: Build clan-app for x86_64-darwin
run: |
nix build .#packages.x86_64-darwin.clan-app --system x86_64-darwin --log-format bar-with-logs

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash
#!/bin/sh
# Shared script for creating pull requests in Gitea workflows
set -euo pipefail
set -eu
# Required environment variables:
# - CI_BOT_TOKEN: Gitea bot token for authentication
@@ -8,22 +9,22 @@ set -euo pipefail
# - PR_TITLE: Title of the pull request
# - PR_BODY: Body/description of the pull request
if [[ -z "${CI_BOT_TOKEN:-}" ]]; then
if [ -z "${CI_BOT_TOKEN:-}" ]; then
echo "Error: CI_BOT_TOKEN is not set" >&2
exit 1
fi
if [[ -z "${PR_BRANCH:-}" ]]; then
if [ -z "${PR_BRANCH:-}" ]; then
echo "Error: PR_BRANCH is not set" >&2
exit 1
fi
if [[ -z "${PR_TITLE:-}" ]]; then
if [ -z "${PR_TITLE:-}" ]; then
echo "Error: PR_TITLE is not set" >&2
exit 1
fi
if [[ -z "${PR_BODY:-}" ]]; then
if [ -z "${PR_BODY:-}" ]; then
echo "Error: PR_BODY is not set" >&2
exit 1
fi
@@ -43,9 +44,12 @@ resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
}" \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
pr_number=$(echo "$resp" | jq -r '.number')
if ! pr_number=$(echo "$resp" | jq -r '.number'); then
echo "Error parsing response from pull request creation" >&2
exit 1
fi
if [[ "$pr_number" == "null" ]]; then
if [ "$pr_number" = "null" ]; then
echo "Error creating pull request:" >&2
echo "$resp" | jq . >&2
exit 1
@@ -64,12 +68,15 @@ while true; do
"delete_branch_after_merge": true
}' \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
msg=$(echo "$resp" | jq -r '.message')
if [[ "$msg" != "Please try again later" ]]; then
if ! msg=$(echo "$resp" | jq -r '.message'); then
echo "Error parsing merge response" >&2
exit 1
fi
if [ "$msg" != "Please try again later" ]; then
break
fi
echo "Retrying in 2 seconds..."
sleep 2
done
echo "Pull request #$pr_number merge initiated"
echo "Pull request #$pr_number merge initiated"

View File

@@ -38,7 +38,6 @@
recommendedOptimisation = lib.mkDefault true;
recommendedProxySettings = lib.mkDefault true;
recommendedTlsSettings = lib.mkDefault true;
recommendedZstdSettings = lib.mkDefault true;
# Nginx sends all the access logs to /var/log/nginx/access.log by default.
# instead of going to the journal!

View File

@@ -0,0 +1,47 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/internet";
manifest.description = "direct access (or via ssh jumphost) to machines";
manifest.categories = [
"System"
"Network"
];
roles.default = {
interface =
{ lib, ... }:
{
options = {
host = lib.mkOption {
type = lib.types.str;
description = ''
ip address or hostname (domain) of the machine
'';
};
jumphosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
optional list of jumphosts to use to connect to the machine
'';
};
};
};
perInstance =
{
roles,
lib,
settings,
...
}:
{
exports.networking = {
# TODO add user space network support to clan-cli
peers = lib.mapAttrs (_name: machine: {
host.plain = machine.settings.host;
SSHOptions = map (_x: "-J x") machine.settings.jumphosts;
}) roles.default.machines;
};
};
};
}

View File

@@ -0,0 +1,9 @@
{ lib, ... }:
let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules = {
internet = module;
};
}

View File

@@ -0,0 +1,110 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/tor";
manifest.description = "Onion routing, use Hidden services to connect your machines";
manifest.categories = [
"System"
"Network"
];
roles.client = {
perInstance =
{
...
}:
{
nixosModule =
{
...
}:
{
config = {
services.tor = {
enable = true;
torsocks.enable = true;
client.enable = true;
};
};
};
};
};
roles.server = {
# interface =
# { lib, ... }:
# {
# options = {
# OciSettings = lib.mkOption {
# type = lib.types.raw;
# default = null;
# description = "NixOS settings for virtualisation.oci-container.<name>.settings";
# };
# buildContainer = lib.mkOption {
# type = lib.types.nullOr lib.types.str;
# default = null;
# };
# };
# };
perInstance =
{
instanceName,
roles,
lib,
...
}:
{
exports.networking = {
priority = lib.mkDefault 10;
# TODO add user space network support to clan-cli
module = "clan_lib.network.tor";
peers = lib.mapAttrs (name: machine: {
host.var = {
machine = name;
generator = "tor_${instanceName}";
file = "hostname";
};
}) roles.server.machines;
};
nixosModule =
{
pkgs,
config,
...
}:
{
config = {
services.tor = {
enable = true;
relay.onionServices."clan_${instanceName}" = {
version = 3;
# TODO get ports from instance machine config
map = [
{
port = 22;
target.port = 22;
}
];
secretKey = config.clan.core.vars.generators."tor_${instanceName}".files.hs_ed25519_secret_key.path;
};
};
clan.core.vars.generators."tor_${instanceName}" = {
files.hs_ed25519_secret_key = { };
files.hostname = { };
runtimeInputs = with pkgs; [
coreutils
tor
];
script = ''
mkdir -p data
echo -e "DataDirectory ./data\nSocksPort 0\nHiddenServiceDir ./hs\nHiddenServicePort 80 127.0.0.1:80" > torrc
timeout 2 tor -f torrc || :
mv hs/hs_ed25519_secret_key $out/hs_ed25519_secret_key
mv hs/hostname $out/hostname
'';
};
};
};
};
};
}

View File

@@ -0,0 +1,9 @@
{ lib, ... }:
let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules = {
tor = module;
};
}

View File

@@ -39,7 +39,7 @@ in
};
perInstance =
{ settings, ... }:
{ instanceName, settings, ... }:
{
nixosModule =
{ pkgs, config, ... }:
@@ -86,7 +86,7 @@ in
# service to generate the environment file containing all secrets, as
# expected by the nixos NetworkManager-ensure-profile service
systemd.services.NetworkManager-setup-secrets = {
systemd.services."NetworkManager-setup-secrets-${instanceName}" = {
description = "Generate wifi secrets for NetworkManager";
requiredBy = [ "NetworkManager-ensure-profiles.service" ];
partOf = [ "NetworkManager-ensure-profiles.service" ];

View File

@@ -7,8 +7,16 @@
inventory = {
machines.test = { };
machines.second = { };
instances = {
wg-test-all = {
module.name = "@clan/wifi";
module.input = "self";
roles.default.tags.all = { };
roles.default.settings.networks.all = { };
};
wg-test-one = {
module.name = "@clan/wifi";
module.input = "self";

View File

@@ -11,7 +11,7 @@
...
}:
let
buildClanOptions = self'.legacyPackages.clan-internals-docs;
clanOptions = self'.legacyPackages.clan-internals-docs;
# Simply evaluated options (JSON)
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
@@ -99,7 +99,7 @@
# 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
export BUILD_CLAN_PATH=${clanOptions}/share/doc/nixos/options.json
mkdir $out

View File

@@ -465,6 +465,10 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
service_links: dict[str, dict[str, dict[str, Any]]] = json.load(f3)
for module_name, module_info in service_links.items():
# Skip specific modules that are not ready for documentation
if module_name in ["internet", "tor"]:
continue
output = f"# {module_name}\n\n"
# output += f"`clan.modules.{module_name}`\n"
output += f"*{module_info['manifest']['description']}*\n"

View File

@@ -35,6 +35,37 @@ services = {
};
```
### Complex Example: Multi-service Setup
```nix
# Old format
services = {
borgbackup.production = {
roles.server.machines = [ "backup-server" ];
roles.server.config = {
directory = "/var/backup/borg";
};
roles.client.tags = [ "backup" ];
roles.client.extraModules = [ "nixosModules/borgbackup.nix" ];
};
zerotier.company-network = {
roles.controller.machines = [ "network-controller" ];
roles.moon.machines = [ "moon-1" "moon-2" ];
roles.peer.tags = [ "nixos" ];
};
sshd.internal = {
roles.server.tags = [ "nixos" ];
roles.client.tags = [ "nixos" ];
config.certificate.searchDomains = [
"internal.example.com"
"vpn.example.com"
];
};
};
```
---
## ✅ After: New `instances` Definition with `clanServices`
@@ -70,6 +101,56 @@ instances = {
};
```
### Complex Example Migrated
```nix
# New format
instances = {
borgbackup-production = {
module = {
name = "borgbackup";
input = "clan-core";
};
roles.server.machines."backup-server" = { };
roles.server.settings = {
directory = "/var/backup/borg";
};
roles.client.tags.backup = { };
roles.client.extraModules = [ ../nixosModules/borgbackup.nix ];
};
zerotier-company-network = {
module = {
name = "zerotier";
input = "clan-core";
};
roles.controller.machines."network-controller" = { };
roles.moon.machines."moon-1".settings = {
stableEndpoints = [ "10.0.0.1" "2001:db8::1" ];
};
roles.moon.machines."moon-2".settings = {
stableEndpoints = [ "10.0.0.2" "2001:db8::2" ];
};
roles.peer.tags.nixos = { };
};
sshd-internal = {
module = {
name = "sshd";
input = "clan-core";
};
roles.server.tags.nixos = { };
roles.client.tags.nixos = { };
roles.client.settings = {
certificate.searchDomains = [
"internal.example.com"
"vpn.example.com"
];
};
};
};
```
---
## Steps to Migrate
@@ -131,6 +212,33 @@ roles.default.machines."test-inventory-machine".settings = {
};
```
### Important Type Changes
The new `instances` format uses **attribute sets** instead of **lists** for tags and machines:
```nix
# ❌ Old format (lists)
roles.client.tags = [ "backup" ];
roles.server.machines = [ "blob64" ];
# ✅ New format (attribute sets)
roles.client.tags.backup = { };
roles.server.machines.blob64 = { };
```
### Handling Multiple Machines/Tags
When you need to assign multiple machines or tags to a role:
```nix
# ❌ Old format
roles.moon.machines = [ "eva" "eve" ];
# ✅ New format - each machine gets its own attribute
roles.moon.machines.eva = { };
roles.moon.machines.eve = { };
```
---
!!! Warning
@@ -138,8 +246,89 @@ roles.default.machines."test-inventory-machine".settings = {
* `inventory.services` is no longer recommended; use `inventory.instances` instead.
* Module authors should begin exporting service modules under the `clan.modules` attribute of their flake.
## Troubleshooting Common Migration Errors
### Error: "not of type `attribute set of (submodule)`"
This error occurs when using lists instead of attribute sets for tags or machines:
```
error: A definition for option `flake.clan.inventory.instances.borgbackup-blob64.roles.client.tags' is not of type `attribute set of (submodule)'.
```
**Solution**: Convert lists to attribute sets as shown in the "Important Type Changes" section above.
### Error: "unsupported attribute `module`"
This error indicates the module structure is incorrect:
```
error: Module ':anon-4:anon-1' has an unsupported attribute `module'.
```
**Solution**: Ensure the `module` attribute has exactly two fields: `name` and `input`.
### Error: "attribute 'pkgs' missing"
This suggests the instance configuration is trying to use imports incorrectly:
```
error: attribute 'pkgs' missing
```
**Solution**: Use the `module = { name = "..."; input = "..."; }` format instead of `imports`.
### Removed Features
The following features from the old `services` format are no longer supported in `instances`:
- Top-level `config` attribute (use `roles.<role>.settings` instead)
- Direct module imports (use the `module` declaration instead)
### extraModules Support
The `extraModules` attribute is still supported in the new instances format! The key change is how modules are specified:
**Old format (string paths relative to clan root):**
```nix
roles.client.extraModules = [ "nixosModules/borgbackup.nix" ];
```
**New format (NixOS modules):**
```nix
# Direct module reference
roles.client.extraModules = [ ../nixosModules/borgbackup.nix ];
# Or using self
roles.client.extraModules = [ self.nixosModules.borgbackup ];
# Or inline module definition
roles.client.extraModules = [
{ config, ... }: {
# Your module configuration here
}
];
```
The `extraModules` now expects actual **NixOS modules** rather than string paths. This provides better type checking and more flexibility in how modules are specified.
**Alternative: Using @clan/importer**
For scenarios where you need to import modules with specific tag-based targeting, you can also use the dedicated `@clan/importer` service:
```nix
instances = {
my-importer = {
module.name = "@clan/importer";
module.input = "clan-core";
roles.default.tags.my-tag = { };
roles.default.extraModules = [ self.nixosModules.myModule ];
};
};
```
## Further reference
* [Authoring a 'clan.service' module](../authoring/clanServices/index.md)
* [ClanServices](../clanServices.md)
* [Inventory Reference](../../reference/nix-api/inventory.md)
* [Inventory Reference](../../reference/nix-api/inventory.md)

20
flake.lock generated
View File

@@ -13,11 +13,11 @@
]
},
"locked": {
"lastModified": 1752589312,
"narHash": "sha256-BafZOenlzMYdumG12AzgVLhEVu+GcEa8nYNDSIYe1U0=",
"rev": "496bbf05a2aa7b061ef464254db5804d1c6f45b4",
"lastModified": 1753067306,
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/496bbf05a2aa7b061ef464254db5804d1c6f45b4.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/18dfd42bdb2cfff510b8c74206005f733e38d8b9.tar.gz"
},
"original": {
"type": "tarball",
@@ -31,11 +31,11 @@
]
},
"locked": {
"lastModified": 1752541678,
"narHash": "sha256-dyhGzkld6jPqnT/UfGV2oqe7tYn7hppAqFvF3GZTyXY=",
"lastModified": 1752718651,
"narHash": "sha256-PkaR0qmyP9q/MDN3uYa+RLeBA0PjvEQiM0rTDDBXkL8=",
"owner": "nix-community",
"repo": "disko",
"rev": "2bf3421f7fed5c84d9392b62dcb9d76ef09796a7",
"rev": "d5ad4485e6f2edcc06751df65c5e16572877db88",
"type": "github"
},
"original": {
@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1752055615,
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=",
"lastModified": 1753006367,
"narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9",
"rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
"type": "github"
},
"original": {

View File

@@ -30,7 +30,6 @@
inputs = {
flake-parts.follows = "flake-parts";
nixpkgs.follows = "nixpkgs";
systems.follows = "systems";
treefmt-nix.follows = "treefmt-nix";
};
};

View File

@@ -21,7 +21,6 @@ lib.fix (
{
inherit (buildClanLib)
buildClan
clan
;
/**

View File

@@ -78,7 +78,87 @@ in
internal = true;
visible = false;
type = types.deferredModule;
default = { };
default = {
options.networking = lib.mkOption {
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
priority = lib.mkOption {
type = lib.types.int;
default = 1000;
description = ''
priority with which this network should be tried.
higher priority means it gets used earlier in the chain
'';
};
module = lib.mkOption {
# type = lib.types.enum [
# "clan_lib.network.direct"
# "clan_lib.network.tor"
# ];
type = lib.types.str;
default = "clan_lib.network.direct";
description = ''
the technology this network uses to connect to the target
This is used for userspace networking with socks proxies.
'';
};
# should we call this machines? hosts?
peers = lib.mkOption {
# <name>
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
};
SSHOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
};
host = lib.mkOption {
description = '''';
type = lib.types.attrTag {
plain = lib.mkOption {
type = lib.types.str;
description = ''
a plain value, which can be read directly from the config
'';
};
var = lib.mkOption {
type = lib.types.submodule {
options = {
machine = lib.mkOption {
type = lib.types.str;
example = "jon";
};
generator = lib.mkOption {
type = lib.types.str;
example = "tor-ssh";
};
file = lib.mkOption {
type = lib.types.str;
example = "hostname";
};
};
};
};
};
};
};
}
)
);
};
};
}
);
};
};
description = ''
A module that is used to define the module of flake level exports -

View File

@@ -5,11 +5,7 @@
clan-core,
...
}:
rec {
buildClan =
# TODO: Once all templates and docs are migrated add: lib.warn "'buildClan' is deprecated. Use 'clan-core.lib.clan' instead"
module: (clan module).config;
{
clan =
{
self ? lib.warn "Argument: 'self' must be set" null, # Reference to the current flake

View File

@@ -48,6 +48,7 @@ in
{
options = {
instances = lib.mkOption {
default = { };
# instances.<instanceName>...
type = types.attrsOf (submoduleWith {
modules = [
@@ -57,6 +58,7 @@ in
};
# instances.<machineName>...
machines = lib.mkOption {
default = { };
type = types.attrsOf (submoduleWith {
modules = [
config.exportsModule

View File

@@ -29,10 +29,7 @@ def _get_lib_names() -> list[str]:
msg = f"Unsupported architecture: {machine}"
raise RuntimeError(msg)
if system == "darwin":
if machine == "arm64":
return ["libwebview.dylib"]
msg = "Not supported"
raise RuntimeError(msg)
return ["libwebview.dylib"]
# linux
return ["libwebview.so"]

View File

@@ -21,6 +21,12 @@ buildNpmPackage (_finalAttrs: {
mkdir -p api
cp -r ${clan-ts-api}/* api
cp -r ${fonts} ".fonts"
# only needed for the next couple weeks to make sure this file doesn't make it back into the git history
if [[ -f "${./ui}/src/routes/Onboarding/background.jpg" ]]; then
echo "background.jpg found, exiting"
exit 1
fi
'';
# todo figure out why this fails only inside of Nix

View File

@@ -3,7 +3,7 @@ import type { StorybookConfig } from "@kachurun/storybook-solid-vite";
const config: StorybookConfig = {
framework: "@kachurun/storybook-solid-vite",
stories: ["../src/components/**/*.mdx", "../src/components/**/*.stories.tsx"],
stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"],
addons: [
"@storybook/addon-links",
"@storybook/addon-docs",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -138,6 +138,10 @@
transition: all 0.5s ease;
}
}
& > span.typography {
@apply max-w-full overflow-hidden whitespace-nowrap text-ellipsis;
}
}
/* button group */

View File

@@ -9,6 +9,7 @@ import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
import { Checkbox } from "@/src/components/Form/Checkbox";
import { FieldProps } from "./Field";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
const FieldsetExamples = (props: FieldsetProps) => (
<div class="flex flex-col gap-8">
@@ -26,7 +27,7 @@ const meta = {
<div
class={cx({
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
"w-[1024px]": context.args.orientation == "horizontal",
"w-[512px]": context.args.orientation == "horizontal",
"bg-inv-acc-3": context.args.inverted,
})}
>
@@ -63,6 +64,11 @@ export const Default: Story = {
label="Bio"
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
/>
<HostFileInput
{...props}
label="Profile pic"
onSelectFile={async () => "/home/foo/bar/baz/fizz/buzz/bla/bizz"}
/>
<Checkbox {...props} label="Accept Terms" required={true} />
</>
),

View File

@@ -1,5 +1,11 @@
div.form-field.host-file {
button {
@apply w-1/2;
@apply w-fit;
}
&.horizontal {
button {
@apply grow max-w-[18rem];
}
}
}

View File

@@ -58,7 +58,7 @@ export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
onSelectFile: async () => {
return "/home/bob/clans/my-clan";
return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz";
},
input: {
placeholder: "e.g. 11/06/89",

View File

@@ -12,6 +12,8 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { createSignal } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography";
export type HostFileInputProps = FieldProps &
TextFieldRootProps & {
@@ -20,10 +22,21 @@ export type HostFileInputProps = FieldProps &
};
export const HostFileInput = (props: HostFileInputProps) => {
const [value, setValue] = createSignal<string | undefined>(undefined);
const [value, setValue] = createSignal<string>(props.value || "");
let actualInputElement: HTMLInputElement | undefined;
const selectFile = async () => {
setValue(await props.onSelectFile());
try {
console.log("selecting file", props.onSelectFile);
setValue(await props.onSelectFile());
actualInputElement?.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
} catch (error) {
console.log("Error selecting file", error);
// todo work out how to display the error
}
};
return (
@@ -33,26 +46,65 @@ export const HostFileInput = (props: HostFileInputProps) => {
ghost: props.ghost,
})}
{...props}
value={value()}
onChange={setValue}
>
<Orienter orientation={props.orientation} align={"start"}>
<Orienter
orientation={props.orientation}
align={props.orientation == "horizontal" ? "center" : "start"}
>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.Input {...props.input} hidden={true} />
<TextField.Input
{...props.input}
hidden={true}
value={value()}
ref={(el: HTMLInputElement) => {
actualInputElement = el; // Capture for local use
}}
/>
<Button
hierarchy="secondary"
size={props.size}
startIcon="Folder"
onClick={selectFile}
>
{value() ? value() : "No Selection"}
</Button>
{!value() && (
<Button
hierarchy="secondary"
size={props.size}
startIcon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
>
No Selection
</Button>
)}
{value() && (
<Tooltip placement="top">
<Tooltip.Portal>
<Tooltip.Content class="tooltip-content">
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{value()}
</Typography>
<Tooltip.Arrow />
</Tooltip.Content>
</Tooltip.Portal>
<Tooltip.Trigger
as={Button}
hierarchy="secondary"
size={props.size}
startIcon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
>
{value()}
</Tooltip.Trigger>
</Tooltip>
)}
</Orienter>
</TextField>
);

View File

@@ -22,40 +22,3 @@ div.form-label {
}
}
}
div.tooltip-content {
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;
max-width: min(calc(100vw - 16px), 380px);
transform-origin: var(--kb-tooltip-content-transform-origin);
animation: tooltipHide 250ms ease-in forwards;
&[data-expanded] {
animation: tooltipShow 250ms ease-out;
}
&.inverted {
@apply bg-def-2;
}
}
@keyframes tooltipShow {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tooltipHide {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -1,12 +1,11 @@
import { Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { Tooltip as KTooltip } from "@kobalte/core/tooltip";
import { Tooltip } from "@/src/components/Tooltip/Tooltip";
import Icon from "@/src/components/Icon/Icon";
import { TextField } from "@kobalte/core/text-field";
import { Checkbox } from "@kobalte/core/checkbox";
import { Combobox } from "@kobalte/core/combobox";
import "./Label.css";
import cx from "classnames";
export type Size = "default" | "s";
@@ -49,31 +48,27 @@ export const Label = (props: LabelProps) => {
{props.label}
</Typography>
{props.tooltip && (
<KTooltip placement="top">
<KTooltip.Trigger>
<Tooltip
placement="top"
inverted={props.inverted}
trigger={
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
<KTooltip.Portal>
<KTooltip.Content
class={cx("tooltip-content", { inverted: props.inverted })}
>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{props.tooltip}
</Typography>
<KTooltip.Arrow />
</KTooltip.Content>
</KTooltip.Portal>
</KTooltip.Trigger>
</KTooltip>
}
>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{props.tooltip}
</Typography>
</Tooltip>
)}
</props.labelComponent>
{props.description && (

View File

@@ -5,7 +5,7 @@ div.orienter {
}
&.horizontal {
@apply flex-row justify-start;
@apply flex-row justify-between gap-0;
& > div.form-label {
@apply w-1/2 shrink;

View File

@@ -1,5 +1,5 @@
div.modal-content {
@apply max-w-[512px];
@apply min-w-[320px] max-w-[512px];
@apply rounded-md;
/* todo replace with a theme() color */
@@ -12,7 +12,7 @@ div.modal-content {
@apply border border-def-2 rounded-tl-md rounded-tr-md;
@apply border-b-def-3;
& > .title {
& > .modal-title {
@apply mx-auto;
}
}

View File

@@ -3,6 +3,7 @@ import { Dialog as KDialog } from "@kobalte/core/dialog";
import "./Modal.css";
import { Typography } from "../Typography/Typography";
import Icon from "../Icon/Icon";
import cx from "classnames";
export interface ModalContext {
close(): void;
@@ -13,6 +14,8 @@ export interface ModalProps {
title: string;
onClose: () => void;
children: (ctx: ModalContext) => JSX.Element;
mount?: Node;
class?: string;
}
export const Modal = (props: ModalProps) => {
@@ -20,18 +23,33 @@ export const Modal = (props: ModalProps) => {
return (
<KDialog id={props.id} open={open()} modal={true}>
<KDialog.Portal>
<KDialog.Content class="modal-content">
<KDialog.Portal mount={props.mount}>
<KDialog.Content class={cx("modal-content", props.class)}>
<div class="header">
<Typography class="title" hierarchy="label" family="mono" size="xs">
<Typography
class="modal-title"
hierarchy="label"
family="mono"
size="xs"
>
{props.title}
</Typography>
<KDialog.CloseButton onClick={() => setOpen(false)}>
<KDialog.CloseButton
onClick={() => {
setOpen(false);
props.onClose();
}}
>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<div class="body">
{props.children({ close: () => setOpen(false) })}
{props.children({
close: () => {
setOpen(false);
props.onClose();
},
})}
</div>
</KDialog.Content>
</KDialog.Portal>

View File

@@ -14,35 +14,35 @@ import { StoryContext } from "@kachurun/storybook-solid-vite";
const sidebarNavProps: SidebarNavProps = {
clanLinks: [
{ label: "Brian's Clan", path: "/clan/1" },
{ label: "Dave's Clan", path: "/clan/2" },
{ label: "Mic92's Clan", path: "/clan/3" },
{ label: "Brian's Clan", path: "/clans/1" },
{ label: "Dave's Clan", path: "/clans/2" },
{ label: "Mic92's Clan", path: "/clans/3" },
],
clanDetail: {
label: "Brian's Clan",
settingsPath: "/clan/1/settings",
settingsPath: "/clans/1/settings",
machines: [
{
label: "Backup & Home",
path: "/clan/1/machine/backup",
path: "/clans/1/machine/backup",
serviceCount: 3,
status: "Online",
},
{
label: "Raspberry Pi",
path: "/clan/1/machine/pi",
path: "/clans/1/machine/pi",
serviceCount: 1,
status: "Offline",
},
{
label: "Mom's Laptop",
path: "/clan/1/machine/moms-laptop",
path: "/clans/1/machine/moms-laptop",
serviceCount: 2,
status: "Installed",
},
{
label: "Dad's Laptop",
path: "/clan/1/machine/dads-laptop",
path: "/clans/1/machine/dads-laptop",
serviceCount: 4,
status: "Not Installed",
},
@@ -52,10 +52,10 @@ const sidebarNavProps: SidebarNavProps = {
{
label: "Tools",
links: [
{ label: "Borgbackup", path: "/clan/1/service/borgbackup" },
{ label: "Syncthing", path: "/clan/1/service/syncthing" },
{ label: "Mumble", path: "/clan/1/service/mumble" },
{ label: "Minecraft", path: "/clan/1/service/minecraft" },
{ label: "Borgbackup", path: "/clans/1/service/borgbackup" },
{ label: "Syncthing", path: "/clans/1/service/syncthing" },
{ label: "Mumble", path: "/clans/1/service/mumble" },
{ label: "Minecraft", path: "/clans/1/service/minecraft" },
],
},
{
@@ -81,7 +81,7 @@ const meta: Meta<RouteSectionProps> = {
component: SidebarNav,
render: (_: never, context: StoryContext<SidebarNavProps>) => {
const history = createMemoryHistory();
history.set({ value: "/clan/1/machine/backup" });
history.set({ value: "/clans/1/machine/backup" });
return (
<div style="height: 670px;">
@@ -93,7 +93,7 @@ const meta: Meta<RouteSectionProps> = {
</Suspense>
)}
>
<Route path="/clan/1/machine/backup" component={(props) => <></>} />
<Route path="/clans/1/machine/backup" component={(props) => <></>} />
</MemoryRouter>
</div>
);

View File

@@ -0,0 +1,9 @@
div.tooltip-content {
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;
max-width: min(calc(100vw - 16px), 380px);
&.inverted {
@apply bg-def-2;
}
}

View File

@@ -0,0 +1,40 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
const meta: Meta<TooltipProps> = {
title: "Components/Tooltip",
component: Tooltip,
decorators: [
(Story: StoryObj<TooltipProps>) => (
<div class="p-16">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<TooltipProps>;
export const Default: Story = {
args: {
placement: "top",
inverted: false,
trigger: <Button hierarchy="primary">Trigger</Button>,
children: (
<Typography hierarchy="body" size="xs" inverted={true} weight="medium">
Your Clan is being created
</Typography>
),
},
};
export const AnimateBounce: Story = {
args: {
...Default.args,
animation: "bounce",
},
};

View File

@@ -0,0 +1,34 @@
import "./Tooltip.css";
import {
Tooltip as KTooltip,
TooltipRootProps as KTooltipRootProps,
} from "@kobalte/core/tooltip";
import cx from "classnames";
import { JSX } from "solid-js";
export interface TooltipProps extends KTooltipRootProps {
inverted?: boolean;
trigger: JSX.Element;
children: JSX.Element;
animation?: "bounce";
}
export const Tooltip = (props: TooltipProps) => {
return (
<KTooltip {...props}>
<KTooltip.Trigger>{props.trigger}</KTooltip.Trigger>
<KTooltip.Portal>
<KTooltip.Content
class={cx("tooltip-content", {
inverted: props.inverted,
"animate-bounce": props.animation == "bounce",
})}
>
{props.placement == "bottom" && <KTooltip.Arrow />}
{props.children}
{props.placement == "top" && <KTooltip.Arrow />}
</KTooltip.Content>
</KTooltip.Portal>
</KTooltip>
);
};

View File

@@ -42,7 +42,7 @@ interface BackendReturnType<K extends OperationNames> {
* @property {Promise<BackendReturnType<K>>} result - A promise that resolves to the return type of the backend operation.
* @property {() => Promise<void>} cancel - A function to cancel the API call, returning a promise that resolves when cancellation is completed.
*/
interface ApiCall<K extends OperationNames> {
export interface ApiCall<K extends OperationNames> {
uuid: string;
result: Promise<OperationResponse<K>>;
cancel: () => Promise<void>;

View File

@@ -1,6 +1,6 @@
import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { Params, Navigator } from "@solidjs/router";
import { Params, Navigator, useParams } from "@solidjs/router";
export const selectClanFolder = async () => {
const req = callApi("get_clan_folder", {});
@@ -21,9 +21,37 @@ export const selectClanFolder = async () => {
};
export const navigateToClan = (navigate: Navigator, uri: string) => {
navigate("/clan/" + window.btoa(uri));
navigate("/clans/" + window.btoa(uri));
};
export const clanURIParam = (params: Params) => {
return window.atob(params.clanURI);
};
export function useClanURI(opts: { force: true }): string;
export function useClanURI(opts: { force: boolean }): string | null;
export function useClanURI(
opts: { force: boolean } = { force: false },
): string | null {
const maybe = () => {
const params = useParams();
if (!params.clanURI) {
return null;
}
const clanURI = clanURIParam(params);
if (!clanURI) {
throw new Error(
"Could not decode clan URI from params: " + params.clanURI,
);
}
return clanURI;
};
const uri = maybe();
if (!uri && opts.force) {
throw new Error(
"ClanURI is not set. Use this function only within contexts, where clanURI is guaranteed to have been set.",
);
}
return uri;
}

View File

@@ -2,7 +2,7 @@
import { render } from "solid-js/web";
import "./index.css";
import { QueryClient } from "@tanstack/solid-query";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { Routes } from "@/src/routes";
import { Router } from "@solidjs/router";
import { Layout } from "@/src/routes/Layout";
@@ -22,4 +22,11 @@ if (import.meta.env.DEV) {
await import("solid-devtools");
}
render(() => <Router root={Layout}>{Routes}</Router>, root!);
render(
() => (
<QueryClientProvider client={client}>
<Router root={Layout}>{Routes}</Router>
</QueryClientProvider>
),
root!,
);

View File

@@ -0,0 +1,31 @@
import { useQuery, UseQueryResult } from "@tanstack/solid-query";
import { callApi, SuccessData } from "../hooks/api";
export type ListMachines = SuccessData<"list_machines">;
export type MachinesQueryResult = UseQueryResult<ListMachines>;
interface MachinesQueryParams {
clanURI: string | null;
}
export const useMachinesQuery = (props: MachinesQueryParams) =>
useQuery<ListMachines>(() => ({
queryKey: ["clans", props.clanURI, "machines"],
enabled: !!props.clanURI,
queryFn: async () => {
if (!props.clanURI) {
return {};
}
const api = callApi("list_machines", {
flake: {
identifier: props.clanURI,
},
});
const result = await api.result;
if (result.status === "error") {
console.error("Error fetching machines:", result.errors);
return {};
}
return result.data;
},
}));

View File

@@ -0,0 +1,13 @@
.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;
}

View File

@@ -1,10 +1,231 @@
import { RouteSectionProps, useParams } from "@solidjs/router";
import { Component } from "solid-js";
import { clanURIParam } from "@/src/hooks/clan";
import { RouteSectionProps } from "@solidjs/router";
import { Component, JSX, Show, createSignal } from "solid-js";
import { useClanURI } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
import { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries";
import { callApi } from "@/src/hooks/api";
import { store, setStore } 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 { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid";
export const Clan: Component<RouteSectionProps> = (props) => {
const params = useParams();
const clanURI = clanURIParam(params);
return <CubeScene />;
return (
<>
<div
style={{
position: "absolute",
top: 0,
}}
>
{props.children}
</div>
<ClanSceneController />
</>
);
};
interface CreateFormValues extends FieldValues {
name: string;
}
interface MockProps {
onClose: () => void;
onSubmit: (formValues: CreateFormValues) => void;
}
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" }}
/>
</>
)}
</Field>
<div class="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>
</div>
);
};
const ClanSceneController = () => {
const clanURI = useClanURI({ force: true });
const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void;
} | null>(null);
const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowModal(true);
setDialogHandlers({ resolve, reject });
});
};
const sendCreate = async (values: CreateFormValues) => {
const api = callApi("create_machine", {
opts: {
clan_dir: {
identifier: clanURI,
},
machine: {
name: values.name,
},
},
});
const res = await api.result;
if (res.status === "error") {
// TODO: Handle displaying errors
console.error("Error creating machine:");
// Important: rejects the promise
throw new Error(res.errors[0].message);
}
return { id: values.name };
};
const [showModal, setShowModal] = createSignal(false);
return (
<SceneDataProvider clanURI={clanURI}>
{({ query }) => {
return (
<>
<Show when={showModal()}>
<MockCreateMachine
onClose={() => {
setShowModal(false);
dialogHandlers()?.reject(new Error("User cancelled"));
}}
onSubmit={async (values) => {
try {
const result = await sendCreate(values);
dialogHandlers()?.resolve(result);
setShowModal(false);
} catch (err) {
dialogHandlers()?.reject(err);
setShowModal(false);
}
}}
/>
</Show>
<div
class="flex flex-row"
style={{ position: "absolute", top: "10px", left: "10px" }}
>
<Button
ghost
onClick={() => {
setStore(
produce((s) => {
for (const machineId in s.sceneData[clanURI]) {
// Reset the position of each machine to [0, 0]
s.sceneData[clanURI] = {}; // Clear the entire object
// delete s.sceneData[clanURI][machineId];
}
}),
);
}}
>
Reset Store
</Button>
<Button
ghost
onClick={() => {
console.log("Refetching API");
query.refetch();
}}
>
Refetch API
</Button>
</div>
{/* TODO: Add minimal display time */}
<div class={cx({ "fade-out": !query.isLoading })}>
<Splash />
</div>
<CubeScene
isLoading={query.isLoading}
cubesQuery={query}
onCreate={onCreate}
sceneStore={() => {
const clanURI = useClanURI({ force: true });
return store.sceneData?.[clanURI];
}}
setMachinePos={(machineId: string, pos: [number, number]) => {
console.log("calling setStore", machineId, pos);
setStore(
produce((s) => {
if (!s.sceneData) {
s.sceneData = {};
}
if (!s.sceneData[clanURI]) {
s.sceneData[clanURI] = {};
}
if (!s.sceneData[clanURI][machineId]) {
s.sceneData[clanURI][machineId] = { position: pos };
} else {
s.sceneData[clanURI][machineId].position = pos;
}
}),
);
}}
/>
</>
);
}}
</SceneDataProvider>
);
};
const SceneDataProvider = (props: {
clanURI: string | null;
children: (sceneData: { query: MachinesQueryResult }) => JSX.Element;
}) => {
const machinesQuery = useMachinesQuery({ clanURI: props.clanURI });
// This component can be used to provide scene data or context if needed
return props.children({ query: machinesQuery });
};

View File

@@ -1,6 +1,18 @@
import { Component } from "solid-js";
import { RouteSectionProps } from "@solidjs/router";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { activeClanURI } from "@/src/stores/clan";
import { navigateToClan } from "@/src/hooks/clan";
export const Layout: Component<RouteSectionProps> = (props) => (
<div class="size-full h-screen">{props.children}</div>
);
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("/");
}
return <div class="size-full h-screen">{props.children}</div>;
};

View File

@@ -0,0 +1,663 @@
div.creating {
@apply flex flex-col items-center justify-center;
div.scene {
width: 400px;
height: 400px;
perspective: 1000px;
/*background: red;*/
& > .frame {
position: relative;
top: 100px;
left: 65px;
width: 200px;
height: 200px;
/*background: green;*/
/*transform: rotate3d(-2, -2, 1, 45deg);*/
transform: rotate3d(-1.5, -2, 0.5, 45deg);
transform-style: preserve-3d;
& > .cube {
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 200px;
transform-style: preserve-3d;
.cube-face {
position: absolute;
width: 100px;
height: 100px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.56) 0%,
rgba(255, 255, 255, 0) 100%
);
border: 1px #10191a solid;
opacity: 1;
&.front {
transform: rotateY(0deg) translateZ(50px);
}
&.right {
transform: rotateY(90deg) translateZ(50px);
}
&.back {
transform: rotateY(180deg) translateZ(50px);
}
&.left {
transform: rotateY(-90deg) translateZ(50px);
}
&.top {
transform: rotateX(90deg) translateZ(50px);
}
&.bottom {
transform: rotateX(-90deg) translateZ(50px);
}
}
&.state-1 {
animation: anim-cube-1-1 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-1-1 {
transform: translateZ(-120px);
animation: anim-cube-1-2 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-2 {
left: 120px;
animation: anim-cube-2-1 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-2-2 {
left: 120px;
transform: translateZ(-120px);
animation: anim-cube-2-2 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-3 {
top: 120px;
animation: anim-cube-3-1 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-3-3 {
top: 120px;
transform: translateZ(-120px);
animation: anim-cube-3-2 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-4 {
top: 120px;
left: 120px;
animation: anim-cube-4-1 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
&.state-4-4 {
top: 120px;
left: 120px;
transform: translateZ(-120px);
animation: anim-cube-4-2 8s 0.32s cubic-bezier(0.34, 1.56, 0.64, 1)
infinite;
}
}
}
}
}
@keyframes anim-cube-1-1 {
/* STEP 1 */
0% {
left: 0px;
transform: translateZ(0px);
}
2.083% {
left: -40px;
transform: translateZ(0px);
}
16.666% {
left: -40px;
transform: translateZ(0px);
}
/* STEP 2 */
18.749% {
left: 0;
transform: translateZ(0px);
}
33.332% {
left: 0;
transform: translateZ(0px);
}
/* STEP 3 */
35.415% {
left: 0;
transform: translateZ(40px);
}
49.998% {
left: 0;
transform: translateZ(40px);
}
/* STEP 4 */
52.081% {
left: 0;
transform: translateZ(0);
}
66.664% {
left: 0;
transform: translateZ(0);
}
/* Step 5 */
68.747% {
left: -60px;
transform: translateZ(60px);
}
83.33% {
left: -60px;
transform: translateZ(60px);
}
/* Step 6 */
85.413% {
left: 0px;
transform: translateZ(0px);
}
100% {
left: 0px;
transform: translateZ(0px);
}
}
@keyframes anim-cube-2-1 {
/* STEP 1 */
0% {
left: 120px;
transform: translateZ(0px);
}
2.083% {
left: 180px;
transform: translateZ(0px);
}
16.666% {
left: 180px;
transform: translateZ(0px);
}
/* STEP 2 */
18.749% {
left: 120px;
transform: translateZ(0px);
}
33.332% {
left: 120px;
transform: translateZ(0px);
}
/* STEP 3 */
35.415% {
left: 240px;
transform: translateZ(120px);
}
49.998% {
left: 240px;
transform: translateZ(120px);
}
/* STEP 4 */
52.081% {
left: 120px;
transform: translateZ(0);
}
66.664% {
left: 120px;
transform: translateZ(0);
}
/* Step 5 */
68.747% {
left: 60px;
transform: translateZ(60px);
}
83.33% {
left: 60px;
transform: translateZ(60px);
}
/* Step 6 */
85.413% {
left: 120px;
transform: translateZ(0px);
}
100% {
left: 120px;
transform: translateZ(0px);
}
}
@keyframes anim-cube-3-1 {
/* STEP 1 */
0% {
top: 120px;
transform: translateZ(0px);
}
2.083% {
top: 220px;
transform: translateZ(0px);
}
16.666% {
top: 220px;
transform: translateZ(0px);
}
/* STEP 2 */
18.749% {
top: 120px;
transform: translateZ(0px);
}
33.332% {
top: 120px;
transform: translateZ(0px);
}
/* STEP 3 */
35.415% {
top: 120px;
transform: translateZ(40px);
}
49.998% {
top: 120px;
transform: translateZ(40px);
}
/* STEP 4 */
52.081% {
top: 120px;
transform: translateZ(0);
}
66.664% {
top: 120px;
transform: translateZ(0);
}
/* Step 5 */
68.747% {
top: 180px;
transform: translateZ(80px);
}
83.33% {
top: 180px;
transform: translateZ(80px);
}
/* Step 6 */
85.413% {
top: 120px;
transform: translateZ(0px);
}
100% {
top: 120px;
transform: translateZ(0px);
}
}
@keyframes anim-cube-4-1 {
/* STEP 1 */
0% {
top: 120px;
left: 120px;
transform: translateZ(0px);
}
2.083% {
top: 220px;
left: 180px;
transform: translateZ(0px);
}
16.666% {
top: 220px;
left: 180px;
transform: translateZ(0px);
}
/* STEP 2 */
18.749% {
top: 120px;
left: 120px;
transform: translateZ(0px);
}
33.332% {
top: 120px;
left: 120px;
transform: translateZ(0px);
}
/* STEP 3 */
35.415% {
top: 120px;
left: 240px;
transform: translateZ(120px);
}
49.998% {
top: 120px;
left: 240px;
transform: translateZ(120px);
}
/* STEP 4 */
52.081% {
top: 120px;
left: 120px;
transform: translateZ(0);
}
66.664% {
top: 120px;
left: 120px;
transform: translateZ(0);
}
/* Step 5 */
68.747% {
top: 180px;
left: 260px;
transform: translateZ(80px);
}
83.33% {
top: 180px;
left: 260px;
transform: translateZ(80px);
}
/* Step 6 */
85.413% {
top: 120px;
left: 120px;
transform: translateZ(0px);
}
100% {
top: 120px;
left: 120px;
transform: translateZ(0px);
}
}
@keyframes anim-cube-1-2 {
/* STEP 1 */
0% {
left: 0px;
transform: translateZ(-120px);
}
2.083% {
left: -40px;
transform: translateZ(-120px);
}
16.666% {
left: -40px;
transform: translateZ(-120px);
}
/* STEP 2 */
18.749% {
left: 0px;
transform: translateZ(-120px);
}
33.332% {
left: 0px;
transform: translateZ(-120px);
}
/* STEP 3 */
35.415% {
left: 0px;
transform: translateZ(-200px);
}
49.998% {
left: 0px;
transform: translateZ(-200px);
}
/* STEP 4 */
52.081% {
left: 0px;
transform: translateZ(-120px);
}
66.664% {
left: 0px;
transform: translateZ(-120px);
}
/* Step 5 */
68.747% {
left: -60px;
transform: translateZ(-180px);
}
83.33% {
left: -60px;
transform: translateZ(-180px);
}
/* Step 6 */
85.413% {
left: 0px;
transform: translateZ(-120px);
}
100% {
left: 0px;
transform: translateZ(-120px);
}
}
@keyframes anim-cube-2-2 {
/* STEP 1 */
0% {
left: 120px;
transform: translateZ(-120px);
}
2.083% {
left: 180px;
transform: translateZ(-120px);
}
16.666% {
left: 180px;
transform: translateZ(-120px);
}
/* STEP 2 */
18.749% {
left: 120px;
transform: translateZ(-120px);
}
33.332% {
left: 120px;
transform: translateZ(-120px);
}
/* STEP 3 */
35.415% {
left: 240px;
transform: translateZ(-200px);
}
49.998% {
left: 240px;
transform: translateZ(-200px);
}
/* STEP 4 */
52.081% {
left: 120px;
transform: translateZ(-120px);
}
66.664% {
left: 120px;
transform: translateZ(-120px);
}
/* Step 5 */
68.747% {
left: 60px;
transform: translateZ(-180px);
}
83.33% {
left: 60px;
transform: translateZ(-180px);
}
/* Step 6 */
85.413% {
left: 120px;
transform: translateZ(-120px);
}
100% {
left: 120px;
transform: translateZ(-120px);
}
}
@keyframes anim-cube-3-2 {
/* STEP 1 */
0% {
top: 120px;
transform: translateZ(-120px);
}
2.083% {
top: 220px;
transform: translateZ(-120px);
}
16.666% {
top: 220px;
transform: translateZ(-120px);
}
/* STEP 2 */
18.749% {
top: 120px;
transform: translateZ(-120px);
}
33.332% {
top: 120px;
transform: translateZ(-120px);
}
/* STEP 3 */
35.415% {
top: 120px;
transform: translateZ(-200px);
}
49.998% {
top: 120px;
transform: translateZ(-200px);
}
/* STEP 4 */
52.081% {
top: 120px;
transform: translateZ(-120px);
}
66.664% {
top: 120px;
transform: translateZ(-120px);
}
/* Step 5 */
68.747% {
top: 180px;
transform: translateZ(-180px);
}
83.33% {
top: 180px;
transform: translateZ(-180px);
}
/* Step 6 */
85.413% {
top: 120px;
transform: translateZ(-120px);
}
100% {
top: 120px;
transform: translateZ(-120px);
}
}
@keyframes anim-cube-4-2 {
/* STEP 1 */
0% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
2.083% {
top: 220px;
left: 180px;
transform: translateZ(-120px);
}
16.666% {
top: 220px;
left: 180px;
transform: translateZ(-120px);
}
/* STEP 2 */
18.749% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
33.332% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
/* STEP 3 */
35.415% {
top: 120px;
left: 240px;
transform: translateZ(-200px);
}
49.998% {
top: 120px;
left: 240px;
transform: translateZ(-200px);
}
/* STEP 4 */
52.081% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
66.664% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
/* Step 5 */
68.747% {
top: 180px;
left: 260px;
transform: translateZ(-180px);
}
83.33% {
top: 180px;
left: 260px;
transform: translateZ(-180px);
}
/* Step 6 */
85.413% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
100% {
top: 120px;
left: 120px;
transform: translateZ(-120px);
}
}

View File

@@ -0,0 +1,90 @@
import { Tooltip } from "@/src/components/Tooltip/Tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import "./Creating.css";
export const Creating = () => (
<div class="creating">
<Tooltip open={true} placement="top" trigger={<div />}>
<Typography hierarchy="body" size="xs" weight="medium" inverted={true}>
Your Clan is being created
</Typography>
</Tooltip>
<div class="scene">
<div class="frame">
<div id="cube-1" class="cube state-1">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-2" class="cube state-2">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-3" class="cube state-3">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-4" class="cube state-4">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-1-1" class="cube state-1-1">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-2-2" class="cube state-2-2">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-3-3" class="cube state-3-3">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
<div id="cube-4-4" class="cube state-4-4">
<div class="cube-face front"></div>
<div class="cube-face left"></div>
<div class="cube-face right"></div>
<div class="cube-face top"></div>
<div class="cube-face bottom"></div>
<div class="cube-face back"></div>
</div>
</div>
</div>
</div>
);

View File

@@ -54,8 +54,7 @@ main#welcome {
}
& > div.container {
@apply flex flex-col items-center justify-evenly gap-y-20;
@apply size-fit;
@apply flex flex-col items-center justify-evenly gap-y-20 size-fit;
& > div.welcome {
@apply flex flex-col min-w-80 gap-y-6;
@@ -66,7 +65,7 @@ main#welcome {
}
& > div.setup {
@apply flex flex-col min-w-[520px] gap-y-5;
@apply flex flex-col w-[33rem] gap-y-5;
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
& > div.header {

View File

@@ -1,18 +1,29 @@
import { Component, createSignal, Match, Setter, Show, Switch } from "solid-js";
import {
Accessor,
Component,
createSignal,
Match,
Setter,
Show,
Switch,
} from "solid-js";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import "./Onboarding.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Alert } from "@/src/components/Alert/Alert";
import { Divider } from "@/src/components/Divider/Divider";
import { Logo } from "@/src/components/Logo/Logo";
import { navigateToClan, selectClanFolder } from "@/src/hooks/clan";
import { activeClanURI } from "@/src/stores/clan";
import { activeClanURI, addClanURI, setActiveClanURI } from "@/src/stores/clan";
import {
createForm,
FormStore,
getError,
getErrors,
getValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { TextInput } from "@/src/components/Form/TextInput";
@@ -20,23 +31,32 @@ import { TextArea } from "@/src/components/Form/TextArea";
import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating";
type State = "welcome" | "setup";
type State = "welcome" | "setup" | "creating";
const SetupSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty("Please enter a name.")),
name: v.pipe(
v.string(),
v.nonEmpty("Please enter a name."),
v.regex(
new RegExp("^[a-zA-Z0-9_\\-]+$"),
"Name must be alphanumeric and can contain underscores and dashes, without spaces.",
),
),
description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
directory: v.pipe(
// initial value is undefined, and I can't see how to handle this better in valibot, so for now when the type
// is incorrect we treat it as empty
v.string("Please select a directory."),
v.nonEmpty("Please select a directory."),
),
});
type SetupForm = v.InferInput<typeof SetupSchema>;
interface backgroundProps {
state: State;
form: FormStore<SetupForm>;
}
const background = (props: backgroundProps) => (
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
@@ -71,7 +91,11 @@ const background = (props: backgroundProps) => (
</div>
);
const welcome = (setState: Setter<State>) => {
const welcome = (props: {
setState: Setter<State>;
welcomeError: Accessor<string | undefined>;
setWelcomeError: Setter<string | undefined>;
}) => {
const navigate = useNavigate();
const selectFolder = async () => {
@@ -91,7 +115,23 @@ const welcome = (setState: Setter<State>) => {
Build your <br />
own darknet
</Typography>
<Button hierarchy="secondary" onClick={() => setState("setup")}>
{props.welcomeError() && (
<Alert
type="error"
icon="Info"
title="Your Clan creation failed"
description={props.welcomeError() || ""}
/>
)}
<Button
hierarchy="secondary"
onClick={() => {
// reset welcome error
props.setWelcomeError(undefined);
// move to next step
props.setState("setup");
}}
>
Start building
</Button>
<div class="separator">
@@ -126,13 +166,89 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
const [state, setState] = createSignal<State>("welcome");
// used to display an error in the welcome screen in the event of a failed
// clan creation
const [welcomeError, setWelcomeError] = createSignal<string | undefined>();
//
const [setupForm, { Form, Field }] = createForm<SetupForm>({
validate: valiForm(SetupSchema),
});
const metaError = () => {
const errors = getErrors(setupForm, ["name", "description"]);
return errors ? errors.name || errors.description : undefined;
const formError = () => {
const formErrors = getErrors(setupForm);
return (
formErrors.name ||
formErrors.description ||
formErrors.directory ||
undefined
);
};
const onSelectFile = async () => {
const req = callApi("get_system_file", {
file_request: {
mode: "select_folder",
title: "Select a folder for you new Clan",
},
});
const resp = await req.result;
if (resp.status === "error") {
// just throw the first error, I can't imagine why there would be multiple
// errors for this call
throw new Error(resp.errors[0].message);
}
if (resp.status === "success" && resp.data) {
return resp.data[0];
}
throw new Error("No data returned from api call");
};
const onSubmit: SubmitHandler<SetupForm> = async (
{ name, description, directory },
event,
) => {
const path = `${directory}/${name}`;
const req = callApi("create_clan", {
opts: {
dest: path,
// todo allow users to select a template
template: "minimal",
initial: {
meta: {
name: name,
description: description,
// todo it tries to 'delete' icon if it's not provided
// this logic is unexpected, and needs reviewed.
icon: null,
},
machines: {},
instances: {},
services: {},
},
},
});
setState("creating");
const resp = await req.result;
if (resp.status === "error") {
setWelcomeError(resp.errors[0].message);
setState("welcome");
return;
}
if (resp.status === "success") {
addClanURI(path);
setActiveClanURI(path);
navigateToClan(navigate, path);
}
};
return (
@@ -140,7 +256,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
{background({ form: setupForm, state: state() })}
<div class="container">
<Switch>
<Match when={state() === "welcome"}>{welcome(setState)}</Match>
<Match when={state() === "welcome"}>
{welcome({
setState,
welcomeError,
setWelcomeError,
})}
</Match>
<Match when={state() === "setup"}>
<div class="setup">
@@ -155,8 +277,16 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
Setup
</Typography>
</div>
<Form>
<Fieldset name="meta" error={metaError()}>
<Form onSubmit={onSubmit}>
{formError() && (
<Alert
type="error"
icon="Info"
title="Form error"
description={formError() || ""}
/>
)}
<Fieldset name="meta">
<Field name="name">
{(field, input) => (
<TextInput
@@ -195,15 +325,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Field>
</Fieldset>
<Fieldset
name="location"
error={getError(setupForm, "directory")}
>
<Fieldset name="location">
<Field name="directory">
{(field, input) => (
<HostFileInput
onSelectFile={async () => "test"}
onSelectFile={onSelectFile}
{...field}
value={field.value}
label="Select directory"
orientation="horizontal"
required={true}
@@ -228,6 +356,10 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Form>
</div>
</Match>
<Match when={state() === "creating"}>
<Creating />
</Match>
</Switch>
</div>
</main>

View File

@@ -8,7 +8,41 @@ export const Routes: RouteDefinition[] = [
component: Onboarding,
},
{
path: "/clan/:clanURI",
component: Clan,
path: "/clans",
children: [
{
path: "/",
component: () => (
<h1>
Clans (index) - (Doesnt really exist, just to keep the scene
mounted)
</h1>
),
},
{
path: "/:clanURI",
children: [
{
path: "/",
component: Clan,
},
{
path: "/machines",
children: [
{
path: "/",
component: () => <h1>Machines (Index)</h1>,
},
{
path: "/:machineID",
component: (props) => (
<h1>Machine ID: {props.params.machineID}</h1>
),
},
],
},
],
},
],
},
];

View File

@@ -0,0 +1,15 @@
.cubes-scene-container {
width: 100%;
height: 100vh;
cursor: pointer;
}
.toolbar-container {
position: absolute;
bottom: 10%;
width: 100%;
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
}

View File

@@ -0,0 +1,15 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { CubeScene } from "./cubes";
const meta: Meta = {
title: "scene/cubes",
component: CubeScene,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
#splash {
position: fixed;
inset: 0;
background: linear-gradient(to top, #e3e7e7, #edf1f1);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
pointer-events: none;
}
#splash .content {
display: flex;
flex-direction: column;
align-items: center;
}
.title {
@apply h-8 mb-8;
}
.loader {
@apply h-3 w-60 mb-3;
width: 18rem;
background: repeating-linear-gradient(
-45deg,
#bfd0d2 0px,
#bfd0d2 10px,
#f7f9fa 10px,
#f7f9fa 20px
);
animation: stripe-move 1s linear infinite;
background-size: 28px 28px; /* Sqrt(20^2 + 20^2) ~= 28 */
@apply border-2 border-solid rounded-[3px] border-bg-def-1;
}
@keyframes stripe-move {
0% {
background-position: 0 0;
}
100% {
background-position: 28px 0;
}
}

View File

@@ -0,0 +1,15 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Splash } from "./splash";
const meta: Meta = {
title: "scene/splash",
component: Splash,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {},
};

View File

@@ -0,0 +1,18 @@
import Logo from "@/logos/darknet-builder-logo.svg";
import "./splash.css";
import { Typography } from "../components/Typography/Typography";
export const Splash = () => (
<div id="splash">
<div class="content">
<span class="title">
<Logo />
</span>
<div class="loader"></div>
<Typography hierarchy="label" size="xs" weight="medium">
Loading new Clan
</Typography>
</div>
</div>
);

View File

@@ -1,14 +1,18 @@
import { createStore, produce } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage";
interface ClanStoreType {
export type SceneData = Record<string, { position: [number, number] }>;
export interface ClanStoreType {
clanURIs: string[];
activeClanURI?: string;
sceneData: Record<string, SceneData>;
}
const [store, setStore] = makePersisted(
createStore<ClanStoreType>({
clanURIs: [],
sceneData: {},
}),
{
name: "clanStore",
@@ -22,7 +26,7 @@ const [store, setStore] = makePersisted(
* @function
* @returns {string} The URI of the active clan.
*/
const activeClanURI = (): string | undefined => store.activeClanURI;
const activeClanURI = () => store.activeClanURI;
/**
* Updates the active Clan URI in the store.
@@ -45,8 +49,10 @@ const clanURIs = (): string[] => store.clanURIs;
* @param {string} uri - The URI of the clan to be added.
*
*/
const addClanURI = (uri: string) =>
const addClanURI = (uri: string) => {
setStore("clanURIs", store.clanURIs.length, uri);
setStore("sceneData", uri, {}); // Initialize empty scene data for every new clan URI
};
/**
* Removes a specified URI from the clan URI list and updates the active clan URI.
@@ -80,6 +86,7 @@ const removeClanURI = (uri: string) => {
export {
store,
setStore,
activeClanURI,
setActiveClanURI,
clanURIs,

View File

@@ -1,6 +1,18 @@
{ pkgs, ... }:
{
gtk4,
webkitgtk_6_0,
lib,
clangStdenv,
fetchFromGitea,
gnumake,
cmake,
clang-tools,
pkg-config,
stdenv,
...
}:
pkgs.clangStdenv.mkDerivation {
clangStdenv.mkDerivation {
pname = "webview";
version = "nightly";
@@ -8,7 +20,7 @@ pkgs.clangStdenv.mkDerivation {
# We disallow remote connections from the UI on Linux
# TODO: Disallow remote connections on MacOS
src = pkgs.fetchFromGitea {
src = fetchFromGitea {
domain = "git.clan.lol";
owner = "clan";
repo = "webview";
@@ -37,23 +49,19 @@ pkgs.clangStdenv.mkDerivation {
];
# Dependencies used during the build process, if any
nativeBuildInputs = with pkgs; [
nativeBuildInputs = [
gnumake
cmake
clang-tools
pkg-config
];
buildInputs =
with pkgs;
[
]
++ pkgs.lib.optionals stdenv.isLinux [
webkitgtk_6_0
gtk4
];
buildInputs = lib.optionals stdenv.isLinux [
webkitgtk_6_0
gtk4
];
meta = with pkgs.lib; {
meta = with lib; {
description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)";
homepage = "https://github.com/webview/webview";
license = licenses.mit;

View File

@@ -25,6 +25,7 @@ from .facts import cli as facts
from .flash import cli as flash_cli
from .hyperlink import help_hyperlink
from .machines import cli as machines
from .network import cli as network_cli
from .profiler import profile
from .ssh import deploy_info as ssh_cli
from .vars import cli as vars_cli
@@ -428,6 +429,26 @@ Examples:
)
select.register_parser(parser_select)
parser_network = subparsers.add_parser(
"network",
aliases=["net"],
# TODO: Add help="Manage networks" when network code is ready
# help="Manage networks",
description="Manage networks",
epilog=(
"""
show information about configured networks
Examples:
$ clan network list
Will list networks
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
network_cli.register_parser(parser_network)
parser_state = subparsers.add_parser(
"state",
aliases=["st"],
@@ -462,7 +483,7 @@ For more detailed information, visit: {help_hyperlink("getting-started", "https:
state.register_parser(parser_state)
if argcomplete:
argcomplete.autocomplete(parser, exclude=["morph"])
argcomplete.autocomplete(parser, exclude=["morph", "network", "net"])
register_common_flags(parser)

View File

@@ -0,0 +1,72 @@
# !/usr/bin/env python3
import argparse
from .list import register_list_parser
from .overview import register_overview_parser
from .ping import register_ping_parser
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
list_parser = subparser.add_parser(
"list",
help="list all networks",
epilog=(
"""
This subcommand allows listing all networks
```
[NETWORK1] [PRIORITY] [MODULE] [PEER1, PEER2]
[NETOWKR2] [PRIORITY] [MODULE] [PEER1, PEER2]
```
Examples:
$ clan network list
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
ping_parser = subparser.add_parser(
"ping",
help="ping a machine to check if it's online",
epilog=(
"""
This subcommand allows pinging a machine to check if it's online
Examples:
$ clan network ping machine1
Check machine1 on all networks (in priority order)
$ clan network ping machine1 --network tor
Check machine1 only on the tor network
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_ping_parser(ping_parser)
overview_parser = subparser.add_parser(
"overview",
help="show the overview of all network and hosts",
epilog=(
"""
This command shows the complete state of all networks
Examples:
$ clan network overview
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_overview_parser(overview_parser)

View File

@@ -0,0 +1,64 @@
import argparse
import logging
from clan_lib.flake import Flake
from clan_lib.network.network import networks_from_flake
log = logging.getLogger(__name__)
def list_command(args: argparse.Namespace) -> None:
flake: Flake = args.flake
networks = networks_from_flake(flake)
if not networks:
print("No networks found")
return
# Calculate column widths
col_network = max(12, max(len(name) for name in networks))
col_priority = 8
col_module = max(
10, max(len(net.module_name.split(".")[-1]) for net in networks.values())
)
col_running = 8
# Print header
header = f"{'Network':<{col_network}} {'Priority':<{col_priority}} {'Module':<{col_module}} {'Running':<{col_running}} {'Peers'}"
print(header)
print("-" * len(header))
# Print network entries
for network_name, network in sorted(
networks.items(), key=lambda network: -network[1].priority
):
# Extract simple module name from full module path
module_name = network.module_name.split(".")[-1]
# Create peer list with truncation
peer_names = list(network.peers.keys())
max_peers_shown = 3
if not peer_names:
peers_str = "No peers"
elif len(peer_names) <= max_peers_shown:
peers_str = ", ".join(peer_names)
else:
shown_peers = peer_names[:max_peers_shown]
remaining = len(peer_names) - max_peers_shown
peers_str = f"{', '.join(shown_peers)} ...({remaining} more)"
# Check if network is running
try:
is_running = network.is_running()
running_status = "Yes" if is_running else "No"
except Exception:
running_status = "Error"
print(
f"{network_name:<{col_network}} {network.priority:<{col_priority}} {module_name:<{col_module}} {running_status:<{col_running}} {peers_str}"
)
def register_list_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=list_command)

View File

@@ -0,0 +1,21 @@
import argparse
import logging
from clan_lib.flake import Flake
from clan_lib.network.network import get_network_overview, networks_from_flake
log = logging.getLogger(__name__)
def overview_command(args: argparse.Namespace) -> None:
flake: Flake = args.flake
networks = networks_from_flake(flake)
overview = get_network_overview(networks)
for network_name, network in overview.items():
print(f"{network_name} {'[ONLINE]' if network['status'] else '[OFFLINE]'}")
for peer_name, peer in network["peers"].items():
print(f"\t{peer_name}: {'[OFFLINE]' if not peer else f'[{peer}]'}")
def register_overview_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=overview_command)

View File

@@ -0,0 +1,67 @@
import argparse
import logging
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.network.network import networks_from_flake
log = logging.getLogger(__name__)
def ping_command(args: argparse.Namespace) -> None:
flake: Flake = args.flake
machine = args.machine
network_name = args.network
networks = networks_from_flake(flake)
if not networks:
print("No networks found in the flake")
# If network is specified, only check that network
if network_name:
networks_to_check = [(network_name, networks[network_name])]
else:
# Sort networks by priority (highest first)
networks_to_check = sorted(networks.items(), key=lambda x: -x[1].priority)
found = False
results = []
for net_name, network in networks_to_check:
if machine in network.peers:
found = True
# Check if network technology is running
if not network.is_running():
results.append(f"{machine} ({net_name}): network not running")
continue
# Check if peer is online
ping = network.ping(machine)
results.append(f"{machine} ({net_name}): {ping}")
if not found:
msg = f"Machine '{machine}' not found in any network"
raise ClanError(msg)
# Print all results
for result in results:
print(result)
def register_ping_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"machine",
type=str,
help="Machine name to ping",
)
parser.add_argument(
"--network",
"-n",
type=str,
help="Specific network to use for ping (if not specified, checks all networks)",
)
parser.set_defaults(func=ping_command)

View File

@@ -0,0 +1,98 @@
import contextlib
import importlib
import inspect
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar, cast
T = TypeVar("T")
@dataclass(frozen=True)
class ClassSource:
module_name: str
file_path: Path
object_name: str
line_number: int | None = None
def vscode_clickable_path(self) -> str:
"""Return a VSCode-clickable path for the class source."""
return (
f"{self.module_name}.{self.object_name}: {self.file_path}:{self.line_number}"
if self.line_number is not None
else f"{self.module_name}.{self.object_name}: {self.file_path}"
)
def __repr__(self) -> str:
return self.vscode_clickable_path()
def __str__(self) -> str:
return self.vscode_clickable_path()
def import_with_source[T](
module_name: str,
class_name: str,
base_class: type[T],
*args: Any,
**kwargs: Any,
) -> T:
"""
Import a class from a module and instantiate it with source information.
This function dynamically imports a class and adds source location metadata
that can be used for debugging. The instantiated object will have VSCode-clickable
paths in its string representation.
Args:
module_name: The fully qualified module name to import
class_name: The name of the class to import from the module
base_class: The base class type for type checking
*args: Additional positional arguments to pass to the class constructor
**kwargs: Additional keyword arguments to pass to the class constructor
Returns:
An instance of the imported class with source information
Example:
>>> from .network import NetworkTechnologyBase, ClassSource
>>> tech = import_with_source(
... "clan_lib.network.tor",
... "NetworkTechnology",
... NetworkTechnologyBase
... )
>>> print(tech) # Outputs: ~/Projects/clan-core/.../tor.py:7
"""
# Import the module
module = importlib.import_module(module_name)
# Get the class from the module
cls = getattr(module, class_name)
# Get the line number of the class definition
line_number = None
with contextlib.suppress(Exception):
line_number = inspect.getsourcelines(cls)[1]
# Get the file path
file_path_str = module.__file__
assert file_path_str is not None, f"Module {module_name} file path cannot be None"
# Make the path relative to home for better readability
try:
file_path = Path(file_path_str).relative_to(Path.home())
file_path = Path("~", file_path)
except ValueError:
# If not under home directory, use absolute path
file_path = Path(file_path_str)
# Create source information
source = ClassSource(
module_name=module_name,
file_path=file_path,
object_name=class_name,
line_number=line_number,
)
# Instantiate the class with source information
return cast(T, cls(source, *args, **kwargs))

View File

@@ -0,0 +1,145 @@
import tempfile
from pathlib import Path
from textwrap import dedent
from typing import Any, cast
import pytest
from clan_lib.import_utils import import_with_source
from clan_lib.network.network import NetworkTechnologyBase
def test_import_with_source(tmp_path: Path) -> None:
"""Test importing a class with source information."""
# Create a temporary module file
module_dir = tmp_path / "test_module"
module_dir.mkdir()
# Create __init__.py to make it a package
(module_dir / "__init__.py").write_text("")
# Create a test module with a NetworkTechnology class
test_module_path = module_dir / "test_tech.py"
test_module_path.write_text(
dedent("""
from clan_lib.network.network import NetworkTechnologyBase
class NetworkTechnology(NetworkTechnologyBase):
def __init__(self, source):
super().__init__(source)
self.test_value = "test"
def is_running(self) -> bool:
return True
""")
)
# Add the temp directory to sys.path
import sys
sys.path.insert(0, str(tmp_path))
try:
# Import the class using import_with_source
instance = import_with_source(
"test_module.test_tech",
"NetworkTechnology",
cast(Any, NetworkTechnologyBase),
)
# Verify the instance is created correctly
assert isinstance(instance, NetworkTechnologyBase)
assert instance.is_running() is True
assert hasattr(instance, "test_value")
assert instance.test_value == "test"
# Verify source information
assert instance.source.module_name == "test_module.test_tech"
assert instance.source.file_path.name == "test_tech.py"
assert instance.source.object_name == "NetworkTechnology"
assert instance.source.line_number == 4 # Line where class is defined
# Test string representations
str_repr = str(instance)
assert "test_tech.py:" in str_repr
assert "NetworkTechnology" in str_repr
assert str(instance.source.line_number) in str_repr
repr_repr = repr(instance)
assert "NetworkTechnology" in repr_repr
assert "test_tech.py:" in repr_repr
assert "test_module.test_tech.NetworkTechnology" in repr_repr
finally:
# Clean up sys.path
sys.path.remove(str(tmp_path))
def test_import_with_source_with_args() -> None:
"""Test importing a class with additional constructor arguments."""
# Create a temporary test file
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
dedent("""
from clan_lib.network.network import NetworkTechnologyBase
class NetworkTechnology(NetworkTechnologyBase):
def __init__(self, source, extra_arg, keyword_arg=None):
super().__init__(source)
self.extra_arg = extra_arg
self.keyword_arg = keyword_arg
def is_running(self) -> bool:
return False
""")
)
temp_file = Path(f.name)
# Import module dynamically
import importlib.util
import sys
spec = importlib.util.spec_from_file_location("temp_module", temp_file)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules["temp_module"] = module
spec.loader.exec_module(module)
try:
# Import with additional arguments
instance = import_with_source(
"temp_module",
"NetworkTechnology",
cast(Any, NetworkTechnologyBase),
"extra_value",
keyword_arg="keyword_value",
)
# Verify arguments were passed correctly
assert instance.extra_arg == "extra_value" # type: ignore[attr-defined]
assert instance.keyword_arg == "keyword_value" # type: ignore[attr-defined]
assert instance.source.object_name == "NetworkTechnology"
finally:
# Clean up
del sys.modules["temp_module"]
temp_file.unlink()
def test_import_with_source_module_not_found() -> None:
"""Test error handling when module is not found."""
with pytest.raises(ModuleNotFoundError):
import_with_source(
"non_existent_module", "SomeClass", cast(Any, NetworkTechnologyBase)
)
def test_import_with_source_class_not_found() -> None:
"""Test error handling when class is not found in module."""
with pytest.raises(AttributeError):
import_with_source(
"clan_lib.network.network",
"NonExistentClass",
cast(Any, NetworkTechnologyBase),
)

View File

@@ -0,0 +1,9 @@
from clan_lib.network.network import NetworkTechnologyBase
class NetworkTechnology(NetworkTechnologyBase):
"""Direct network connection technology - checks SSH connectivity"""
def is_running(self) -> bool:
"""Direct connections are always 'running' as they don't require a daemon"""
return True

View File

@@ -0,0 +1,156 @@
import logging
import textwrap
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property
from typing import Any
from clan_cli.vars.get import get_machine_var
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.import_utils import ClassSource, import_with_source
from clan_lib.ssh.parse import parse_ssh_uri
from clan_lib.ssh.remote import Remote, check_machine_ssh_reachable
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Peer:
_host: dict[str, str | dict[str, str]]
flake: Flake
@cached_property
def host(self) -> str:
if "plain" in self._host and isinstance(self._host["plain"], str):
return self._host["plain"]
if "var" in self._host and isinstance(self._host["var"], dict):
_var: dict[str, str] = self._host["var"]
machine_name = _var["machine"]
generator = _var["generator"]
var = get_machine_var(
str(self.flake),
machine_name,
f"{generator}/{_var['file']}",
)
if not var.exists:
msg = (
textwrap.dedent(f"""
It looks like you added a networking module to your machine, but forgot
to deploy your changes. Please run "clan machines update {machine_name}"
so that the appropriate vars are generated and deployed properly.
""")
.rstrip("\n")
.lstrip("\n")
)
raise ClanError(msg)
return var.value.decode()
msg = f"Unknown Var Type {self._host}"
raise ClanError(msg)
@dataclass
class NetworkTechnologyBase(ABC):
source: ClassSource
@abstractmethod
def is_running(self) -> bool:
pass
# TODO this will depend on the network implementation if we do user networking at some point, so it should be abstractmethod
def ping(self, peer: Peer) -> None | float:
if self.is_running():
try:
# Parse the peer's host address to create a Remote object, use peer here since we don't have the machine_name here
remote = parse_ssh_uri(machine_name="peer", address=peer.host)
# Use the existing SSH reachability check
now = time.time()
result = check_machine_ssh_reachable(remote)
if result.ok:
return (time.time() - now) * 1000
return None
except Exception as e:
log.debug(f"Error checking peer {peer.host}: {e}")
return None
return None
@dataclass(frozen=True)
class Network:
peers: dict[str, Peer]
module_name: str
priority: int = 1000
@cached_property
def module(self) -> NetworkTechnologyBase:
res = import_with_source(
self.module_name,
"NetworkTechnology",
NetworkTechnologyBase, # type: ignore[type-abstract]
)
return res
def is_running(self) -> bool:
return self.module.is_running()
def ping(self, peer: str) -> float | None:
return self.module.ping(self.peers[peer])
def networks_from_flake(flake: Flake) -> dict[str, Network]:
networks: dict[str, Network] = {}
networks_ = flake.select("clan.exports.instances.*.networking")
for network_name, network in networks_.items():
if network:
peers: dict[str, Peer] = {}
for _peer in network["peers"].values():
peers[_peer["name"]] = Peer(_host=_peer["host"], flake=flake)
networks[network_name] = Network(
peers=peers,
module_name=network["module"],
priority=network["priority"],
)
return networks
def get_best_remote(machine_name: str, networks: dict[str, Network]) -> Remote | None:
for network_name, network in sorted(
networks.items(), key=lambda network: -network[1].priority
):
if machine_name in network.peers:
if network.is_running() and network.ping(machine_name):
print(f"connecting via {network_name}")
return Remote.from_ssh_uri(
machine_name=machine_name,
address=network.peers[machine_name].host,
)
return None
def get_network_overview(networks: dict[str, Network]) -> dict:
result: dict[str, dict[str, Any]] = {}
for network_name, network in networks.items():
result[network_name] = {}
result[network_name]["status"] = None
result[network_name]["peers"] = {}
network_online = False
module = network.module
log.debug(f"Using network module: {module}")
if module.is_running():
result[network_name]["status"] = True
network_online = True
for peer_name in network.peers:
if network_online:
try:
result[network_name]["peers"][peer_name] = network.ping(peer_name)
except ClanError:
log.warning(
f"getting host for machine: {peer_name} in network: {network_name} failed"
)
else:
result[network_name]["peers"][peer_name] = None
return result

View File

@@ -0,0 +1,106 @@
from typing import Any
from unittest.mock import MagicMock, patch
from clan_lib.flake import Flake
from clan_lib.network.network import Network, Peer, networks_from_flake
@patch("clan_lib.network.network.get_machine_var")
def test_networks_from_flake(mock_get_machine_var: MagicMock) -> None:
# Create a mock flake
flake = MagicMock(spec=Flake)
# Mock the var decryption
def mock_var_side_effect(flake_path: str, machine: str, var_path: str) -> Any:
if machine == "machine1" and var_path == "wireguard/address":
mock_var = MagicMock()
mock_var.value.decode.return_value = "192.168.1.10"
return mock_var
if machine == "machine2" and var_path == "wireguard/address":
mock_var = MagicMock()
mock_var.value.decode.return_value = "192.168.1.11"
return mock_var
return None
mock_get_machine_var.side_effect = mock_var_side_effect
# Define the expected return value from flake.select
mock_networking_data = {
"vpn-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {
"var": {
"machine": "machine1",
"generator": "wireguard",
"file": "address",
}
},
},
"machine2": {
"name": "machine2",
"host": {
"var": {
"machine": "machine2",
"generator": "wireguard",
"file": "address",
}
},
},
},
"module": "clan_lib.network.tor",
"priority": 1000,
},
"local-network": {
"peers": {
"machine1": {
"name": "machine1",
"host": {"plain": "10.0.0.10"},
},
"machine3": {
"name": "machine3",
"host": {"plain": "10.0.0.12"},
},
},
"module": "clan_lib.network.direct",
"priority": 500,
},
}
# Mock the select method
flake.select.return_value = mock_networking_data
# Call the function
networks = networks_from_flake(flake)
# Verify the flake.select was called with the correct pattern
flake.select.assert_called_once_with("clan.exports.instances.*.networking")
# Verify the returned networks
assert len(networks) == 2
assert "vpn-network" in networks
assert "local-network" in networks
# Check vpn-network
vpn_network = networks["vpn-network"]
assert isinstance(vpn_network, Network)
assert vpn_network.module_name == "clan_lib.network.tor"
assert vpn_network.priority == 1000
assert len(vpn_network.peers) == 2
assert "machine1" in vpn_network.peers
assert "machine2" in vpn_network.peers
# Check peer details - this will call get_machine_var to decrypt the var
machine1_peer = vpn_network.peers["machine1"]
assert isinstance(machine1_peer, Peer)
assert machine1_peer.host == "192.168.1.10"
assert machine1_peer.flake == flake
# Check local-network
local_network = networks["local-network"]
assert local_network.module_name == "clan_lib.network.direct"
assert local_network.priority == 500
assert len(local_network.peers) == 2
assert "machine1" in local_network.peers
assert "machine3" in local_network.peers

View File

@@ -0,0 +1,20 @@
from urllib.error import HTTPError
from urllib.request import urlopen
from .network import NetworkTechnologyBase
class NetworkTechnology(NetworkTechnologyBase):
socks_port: int
command_port: int
def is_running(self) -> bool:
"""Check if Tor is running by sending HTTP request to SOCKS port."""
try:
response = urlopen("http://127.0.0.1:9050", timeout=5)
content = response.read().decode("utf-8", errors="ignore")
return "tor" in content.lower()
except HTTPError as e:
return "tor" in str(e).lower()
except Exception:
return False

View File

@@ -139,7 +139,7 @@ class InventoryStore:
def _load_merged_inventory(self) -> InventorySnapshot:
"""
Loads the evaluated inventory.
After all merge operations with eventual nix code in buildClan.
After all merge operations with eventual nix code in lib.clan.
Evaluates clanInternals.inventoryClass.inventory with nix. Which is performant.

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from clan_cli.cli import create_parser
hidden_subcommands = ["machine", "b", "f", "m", "se", "st", "va"]
hidden_subcommands = ["machine", "b", "f", "m", "se", "st", "va", "net", "network"]
@dataclass

View File

@@ -3,12 +3,18 @@
inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs =
{ self, clan-core, ... }:
{
self,
clan-core,
nixpkgs,
...
}@inputs:
let
# Usage see: https://docs.clan.lol
clan = clan-core.lib.clan {
inherit self;
imports = [ ./clan.nix ];
specialArgs = { inherit inputs; };
};
in
{
@@ -16,7 +22,7 @@
# Add the Clan cli tool to the dev shell.
# Use "nix develop" to enter the dev shell.
devShells =
clan-core.inputs.nixpkgs.lib.genAttrs
nixpkgs.lib.genAttrs
[
"x86_64-linux"
"aarch64-linux"