Compare commits
86 Commits
feat/optio
...
remove-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8de49258 | ||
|
|
579492f071 | ||
|
|
0ed02da28f | ||
|
|
4abfbb05a2 | ||
|
|
6126cccbcc | ||
|
|
9e77d16e6d | ||
|
|
53752d4a69 | ||
|
|
38955f763f | ||
|
|
bd97896899 | ||
|
|
d6efeb3295 | ||
|
|
e3247d9c36 | ||
|
|
4055508588 | ||
|
|
ff65dfc883 | ||
|
|
1f5ef04a61 | ||
|
|
89f0e90910 | ||
|
|
137aa71529 | ||
|
|
4b5273fbc1 | ||
|
|
aed48be645 | ||
|
|
5fdc9823d1 | ||
|
|
f6284a7ac2 | ||
|
|
72473746ff | ||
|
|
4b36b3e07c | ||
|
|
5a63eeed4e | ||
|
|
ac96d67f09 | ||
|
|
d01342aa79 | ||
|
|
2d404254da | ||
|
|
71b69c1010 | ||
|
|
f155c68efe | ||
|
|
e57741b60c | ||
|
|
c9cacfcf62 | ||
|
|
2d937b80b1 | ||
|
|
e8b91e63bc | ||
|
|
a9d6fa7712 | ||
|
|
65a23983c2 | ||
|
|
c181400267 | ||
|
|
e8ff0d1ad4 | ||
|
|
f9f8a947e2 | ||
|
|
c5b0154af7 | ||
|
|
864742f05f | ||
|
|
38b043f625 | ||
|
|
174e66ef95 | ||
|
|
315049de20 | ||
|
|
2e577dbd1e | ||
|
|
a9b457e063 | ||
|
|
4281770ec7 | ||
|
|
1bd950fa39 | ||
|
|
e37b61240b | ||
|
|
23d2975bb5 | ||
|
|
d441d4c1c1 | ||
|
|
840cb7e2cb | ||
|
|
cf232e1002 | ||
|
|
7414dc6e7e | ||
|
|
d97f997349 | ||
|
|
0621ae1ca6 | ||
|
|
992048e1b2 | ||
|
|
261cad7674 | ||
|
|
a012e4b1af | ||
|
|
158b98ee05 | ||
|
|
14d367e50f | ||
|
|
48c575699e | ||
|
|
60768cc537 | ||
|
|
c26dff282b | ||
|
|
5022f6f26c | ||
|
|
94b93074bc | ||
|
|
d962033236 | ||
|
|
a548851245 | ||
|
|
b32e61bb6d | ||
|
|
e731322af3 | ||
|
|
fd21c6b4ee | ||
|
|
5a86862f47 | ||
|
|
1d1a2563c3 | ||
|
|
4bc57980ff | ||
|
|
3afd0c0971 | ||
|
|
e6a6cb27ec | ||
|
|
dcd78c5d84 | ||
|
|
2a1ad66292 | ||
|
|
5d0d4404b8 | ||
|
|
7b369c77b5 | ||
|
|
06b70a982b | ||
|
|
c9b1b0fb94 | ||
|
|
66bdbb0959 | ||
|
|
752f030d03 | ||
|
|
8c7e93c92e | ||
|
|
579885a6e2 | ||
|
|
45f7ebc0c9 | ||
|
|
997d675f8c |
20
.gitea/workflows/build-clan-app-darwin.yml
Normal file
20
.gitea/workflows/build-clan-app-darwin.yml
Normal 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/sh
|
||||||
|
|
||||||
# Shared script for creating pull requests in Gitea workflows
|
# Shared script for creating pull requests in Gitea workflows
|
||||||
set -euo pipefail
|
set -eu
|
||||||
|
|
||||||
# Required environment variables:
|
# Required environment variables:
|
||||||
# - CI_BOT_TOKEN: Gitea bot token for authentication
|
# - CI_BOT_TOKEN: Gitea bot token for authentication
|
||||||
@@ -8,22 +9,22 @@ set -euo pipefail
|
|||||||
# - PR_TITLE: Title of the pull request
|
# - PR_TITLE: Title of the pull request
|
||||||
# - PR_BODY: Body/description 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
|
echo "Error: CI_BOT_TOKEN is not set" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${PR_BRANCH:-}" ]]; then
|
if [ -z "${PR_BRANCH:-}" ]; then
|
||||||
echo "Error: PR_BRANCH is not set" >&2
|
echo "Error: PR_BRANCH is not set" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${PR_TITLE:-}" ]]; then
|
if [ -z "${PR_TITLE:-}" ]; then
|
||||||
echo "Error: PR_TITLE is not set" >&2
|
echo "Error: PR_TITLE is not set" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${PR_BODY:-}" ]]; then
|
if [ -z "${PR_BODY:-}" ]; then
|
||||||
echo "Error: PR_BODY is not set" >&2
|
echo "Error: PR_BODY is not set" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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")
|
"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 "Error creating pull request:" >&2
|
||||||
echo "$resp" | jq . >&2
|
echo "$resp" | jq . >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -64,12 +68,15 @@ while true; do
|
|||||||
"delete_branch_after_merge": true
|
"delete_branch_after_merge": true
|
||||||
}' \
|
}' \
|
||||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
|
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
|
||||||
msg=$(echo "$resp" | jq -r '.message')
|
if ! msg=$(echo "$resp" | jq -r '.message'); then
|
||||||
if [[ "$msg" != "Please try again later" ]]; then
|
echo "Error parsing merge response" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$msg" != "Please try again later" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Retrying in 2 seconds..."
|
echo "Retrying in 2 seconds..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Pull request #$pr_number merge initiated"
|
echo "Pull request #$pr_number merge initiated"
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
recommendedOptimisation = lib.mkDefault true;
|
recommendedOptimisation = lib.mkDefault true;
|
||||||
recommendedProxySettings = lib.mkDefault true;
|
recommendedProxySettings = lib.mkDefault true;
|
||||||
recommendedTlsSettings = 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.
|
# Nginx sends all the access logs to /var/log/nginx/access.log by default.
|
||||||
# instead of going to the journal!
|
# instead of going to the journal!
|
||||||
|
|||||||
47
clanServices/internet/default.nix
Normal file
47
clanServices/internet/default.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
9
clanServices/internet/flake-module.nix
Normal file
9
clanServices/internet/flake-module.nix
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{ lib, ... }:
|
||||||
|
let
|
||||||
|
module = lib.modules.importApply ./default.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.modules = {
|
||||||
|
internet = module;
|
||||||
|
};
|
||||||
|
}
|
||||||
110
clanServices/tor/default.nix
Normal file
110
clanServices/tor/default.nix
Normal 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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
9
clanServices/tor/flake-module.nix
Normal file
9
clanServices/tor/flake-module.nix
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{ lib, ... }:
|
||||||
|
let
|
||||||
|
module = lib.modules.importApply ./default.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.modules = {
|
||||||
|
tor = module;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
perInstance =
|
perInstance =
|
||||||
{ settings, ... }:
|
{ instanceName, settings, ... }:
|
||||||
{
|
{
|
||||||
nixosModule =
|
nixosModule =
|
||||||
{ pkgs, config, ... }:
|
{ pkgs, config, ... }:
|
||||||
@@ -86,7 +86,7 @@ in
|
|||||||
|
|
||||||
# service to generate the environment file containing all secrets, as
|
# service to generate the environment file containing all secrets, as
|
||||||
# expected by the nixos NetworkManager-ensure-profile service
|
# 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";
|
description = "Generate wifi secrets for NetworkManager";
|
||||||
requiredBy = [ "NetworkManager-ensure-profiles.service" ];
|
requiredBy = [ "NetworkManager-ensure-profiles.service" ];
|
||||||
partOf = [ "NetworkManager-ensure-profiles.service" ];
|
partOf = [ "NetworkManager-ensure-profiles.service" ];
|
||||||
|
|||||||
@@ -7,8 +7,16 @@
|
|||||||
inventory = {
|
inventory = {
|
||||||
|
|
||||||
machines.test = { };
|
machines.test = { };
|
||||||
|
machines.second = { };
|
||||||
|
|
||||||
instances = {
|
instances = {
|
||||||
|
wg-test-all = {
|
||||||
|
module.name = "@clan/wifi";
|
||||||
|
module.input = "self";
|
||||||
|
roles.default.tags.all = { };
|
||||||
|
roles.default.settings.networks.all = { };
|
||||||
|
};
|
||||||
|
|
||||||
wg-test-one = {
|
wg-test-one = {
|
||||||
module.name = "@clan/wifi";
|
module.name = "@clan/wifi";
|
||||||
module.input = "self";
|
module.input = "self";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
buildClanOptions = self'.legacyPackages.clan-internals-docs;
|
clanOptions = self'.legacyPackages.clan-internals-docs;
|
||||||
# Simply evaluated options (JSON)
|
# Simply evaluated options (JSON)
|
||||||
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
|
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
|
||||||
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
|
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
# Frontmatter format for clanModules
|
# Frontmatter format for clanModules
|
||||||
export CLAN_MODULES_FRONTMATTER_DOCS=${clanModulesFrontmatter}/share/doc/nixos/options.json
|
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
|
mkdir $out
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
service_links: dict[str, dict[str, dict[str, Any]]] = json.load(f3)
|
||||||
|
|
||||||
for module_name, module_info in service_links.items():
|
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"# {module_name}\n\n"
|
||||||
# output += f"`clan.modules.{module_name}`\n"
|
# output += f"`clan.modules.{module_name}`\n"
|
||||||
output += f"*{module_info['manifest']['description']}*\n"
|
output += f"*{module_info['manifest']['description']}*\n"
|
||||||
|
|||||||
@@ -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`
|
## ✅ 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
|
## 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
|
!!! Warning
|
||||||
@@ -138,8 +246,89 @@ roles.default.machines."test-inventory-machine".settings = {
|
|||||||
* `inventory.services` is no longer recommended; use `inventory.instances` instead.
|
* `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.
|
* 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
|
## Further reference
|
||||||
|
|
||||||
* [Authoring a 'clan.service' module](../authoring/clanServices/index.md)
|
* [Authoring a 'clan.service' module](../authoring/clanServices/index.md)
|
||||||
* [ClanServices](../clanServices.md)
|
* [ClanServices](../clanServices.md)
|
||||||
* [Inventory Reference](../../reference/nix-api/inventory.md)
|
* [Inventory Reference](../../reference/nix-api/inventory.md)
|
||||||
|
|||||||
20
flake.lock
generated
20
flake.lock
generated
@@ -13,11 +13,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1752589312,
|
"lastModified": 1753067306,
|
||||||
"narHash": "sha256-BafZOenlzMYdumG12AzgVLhEVu+GcEa8nYNDSIYe1U0=",
|
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
|
||||||
"rev": "496bbf05a2aa7b061ef464254db5804d1c6f45b4",
|
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1752541678,
|
"lastModified": 1752718651,
|
||||||
"narHash": "sha256-dyhGzkld6jPqnT/UfGV2oqe7tYn7hppAqFvF3GZTyXY=",
|
"narHash": "sha256-PkaR0qmyP9q/MDN3uYa+RLeBA0PjvEQiM0rTDDBXkL8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "2bf3421f7fed5c84d9392b62dcb9d76ef09796a7",
|
"rev": "d5ad4485e6f2edcc06751df65c5e16572877db88",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1752055615,
|
"lastModified": 1753006367,
|
||||||
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=",
|
"narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9",
|
"rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
flake-parts.follows = "flake-parts";
|
flake-parts.follows = "flake-parts";
|
||||||
nixpkgs.follows = "nixpkgs";
|
nixpkgs.follows = "nixpkgs";
|
||||||
systems.follows = "systems";
|
|
||||||
treefmt-nix.follows = "treefmt-nix";
|
treefmt-nix.follows = "treefmt-nix";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ lib.fix (
|
|||||||
{
|
{
|
||||||
|
|
||||||
inherit (buildClanLib)
|
inherit (buildClanLib)
|
||||||
buildClan
|
|
||||||
clan
|
clan
|
||||||
;
|
;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -78,7 +78,87 @@ in
|
|||||||
internal = true;
|
internal = true;
|
||||||
visible = false;
|
visible = false;
|
||||||
type = types.deferredModule;
|
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 = ''
|
description = ''
|
||||||
A module that is used to define the module of flake level exports -
|
A module that is used to define the module of flake level exports -
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,7 @@
|
|||||||
clan-core,
|
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 =
|
clan =
|
||||||
{
|
{
|
||||||
self ? lib.warn "Argument: 'self' must be set" null, # Reference to the current flake
|
self ? lib.warn "Argument: 'self' must be set" null, # Reference to the current flake
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ in
|
|||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
instances = lib.mkOption {
|
instances = lib.mkOption {
|
||||||
|
default = { };
|
||||||
# instances.<instanceName>...
|
# instances.<instanceName>...
|
||||||
type = types.attrsOf (submoduleWith {
|
type = types.attrsOf (submoduleWith {
|
||||||
modules = [
|
modules = [
|
||||||
@@ -57,6 +58,7 @@ in
|
|||||||
};
|
};
|
||||||
# instances.<machineName>...
|
# instances.<machineName>...
|
||||||
machines = lib.mkOption {
|
machines = lib.mkOption {
|
||||||
|
default = { };
|
||||||
type = types.attrsOf (submoduleWith {
|
type = types.attrsOf (submoduleWith {
|
||||||
modules = [
|
modules = [
|
||||||
config.exportsModule
|
config.exportsModule
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ def _get_lib_names() -> list[str]:
|
|||||||
msg = f"Unsupported architecture: {machine}"
|
msg = f"Unsupported architecture: {machine}"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
if system == "darwin":
|
if system == "darwin":
|
||||||
if machine == "arm64":
|
return ["libwebview.dylib"]
|
||||||
return ["libwebview.dylib"]
|
|
||||||
msg = "Not supported"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
# linux
|
# linux
|
||||||
return ["libwebview.so"]
|
return ["libwebview.so"]
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ buildNpmPackage (_finalAttrs: {
|
|||||||
mkdir -p api
|
mkdir -p api
|
||||||
cp -r ${clan-ts-api}/* api
|
cp -r ${clan-ts-api}/* api
|
||||||
cp -r ${fonts} ".fonts"
|
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
|
# todo figure out why this fails only inside of Nix
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { StorybookConfig } from "@kachurun/storybook-solid-vite";
|
|||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
framework: "@kachurun/storybook-solid-vite",
|
framework: "@kachurun/storybook-solid-vite",
|
||||||
stories: ["../src/components/**/*.mdx", "../src/components/**/*.stories.tsx"],
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"],
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-links",
|
"@storybook/addon-links",
|
||||||
"@storybook/addon-docs",
|
"@storybook/addon-docs",
|
||||||
|
|||||||
1
pkgs/clan-app/ui/logos/darknet-builder-logo.svg
Normal file
1
pkgs/clan-app/ui/logos/darknet-builder-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
@@ -138,6 +138,10 @@
|
|||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > span.typography {
|
||||||
|
@apply max-w-full overflow-hidden whitespace-nowrap text-ellipsis;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* button group */
|
/* button group */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TextInput } from "@/src/components/Form/TextInput";
|
|||||||
import { TextArea } from "@/src/components/Form/TextArea";
|
import { TextArea } from "@/src/components/Form/TextArea";
|
||||||
import { Checkbox } from "@/src/components/Form/Checkbox";
|
import { Checkbox } from "@/src/components/Form/Checkbox";
|
||||||
import { FieldProps } from "./Field";
|
import { FieldProps } from "./Field";
|
||||||
|
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||||
|
|
||||||
const FieldsetExamples = (props: FieldsetProps) => (
|
const FieldsetExamples = (props: FieldsetProps) => (
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
@@ -26,7 +27,7 @@ const meta = {
|
|||||||
<div
|
<div
|
||||||
class={cx({
|
class={cx({
|
||||||
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
"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,
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -63,6 +64,11 @@ export const Default: Story = {
|
|||||||
label="Bio"
|
label="Bio"
|
||||||
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
|
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} />
|
<Checkbox {...props} label="Accept Terms" required={true} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
div.form-field.host-file {
|
div.form-field.host-file {
|
||||||
button {
|
button {
|
||||||
@apply w-1/2;
|
@apply w-fit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
button {
|
||||||
|
@apply grow max-w-[18rem];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export type Story = StoryObj<typeof meta>;
|
|||||||
export const Bare: Story = {
|
export const Bare: Story = {
|
||||||
args: {
|
args: {
|
||||||
onSelectFile: async () => {
|
onSelectFile: async () => {
|
||||||
return "/home/bob/clans/my-clan";
|
return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz";
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
placeholder: "e.g. 11/06/89",
|
placeholder: "e.g. 11/06/89",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
|||||||
import { FieldProps } from "./Field";
|
import { FieldProps } from "./Field";
|
||||||
import { Orienter } from "./Orienter";
|
import { Orienter } from "./Orienter";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
|
import { Tooltip } from "@kobalte/core/tooltip";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
|
||||||
export type HostFileInputProps = FieldProps &
|
export type HostFileInputProps = FieldProps &
|
||||||
TextFieldRootProps & {
|
TextFieldRootProps & {
|
||||||
@@ -20,10 +22,21 @@ export type HostFileInputProps = FieldProps &
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const HostFileInput = (props: HostFileInputProps) => {
|
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 () => {
|
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 (
|
return (
|
||||||
@@ -33,26 +46,65 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
|||||||
ghost: props.ghost,
|
ghost: props.ghost,
|
||||||
})}
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
value={value()}
|
|
||||||
onChange={setValue}
|
|
||||||
>
|
>
|
||||||
<Orienter orientation={props.orientation} align={"start"}>
|
<Orienter
|
||||||
|
orientation={props.orientation}
|
||||||
|
align={props.orientation == "horizontal" ? "center" : "start"}
|
||||||
|
>
|
||||||
<Label
|
<Label
|
||||||
labelComponent={TextField.Label}
|
labelComponent={TextField.Label}
|
||||||
descriptionComponent={TextField.Description}
|
descriptionComponent={TextField.Description}
|
||||||
{...props}
|
{...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
|
{!value() && (
|
||||||
hierarchy="secondary"
|
<Button
|
||||||
size={props.size}
|
hierarchy="secondary"
|
||||||
startIcon="Folder"
|
size={props.size}
|
||||||
onClick={selectFile}
|
startIcon="Folder"
|
||||||
>
|
onClick={selectFile}
|
||||||
{value() ? value() : "No Selection"}
|
disabled={props.disabled || props.readOnly}
|
||||||
</Button>
|
>
|
||||||
|
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>
|
</Orienter>
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
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 Icon from "@/src/components/Icon/Icon";
|
||||||
import { TextField } from "@kobalte/core/text-field";
|
import { TextField } from "@kobalte/core/text-field";
|
||||||
import { Checkbox } from "@kobalte/core/checkbox";
|
import { Checkbox } from "@kobalte/core/checkbox";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import "./Label.css";
|
import "./Label.css";
|
||||||
import cx from "classnames";
|
|
||||||
|
|
||||||
export type Size = "default" | "s";
|
export type Size = "default" | "s";
|
||||||
|
|
||||||
@@ -49,31 +48,27 @@ export const Label = (props: LabelProps) => {
|
|||||||
{props.label}
|
{props.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{props.tooltip && (
|
{props.tooltip && (
|
||||||
<KTooltip placement="top">
|
<Tooltip
|
||||||
<KTooltip.Trigger>
|
placement="top"
|
||||||
|
inverted={props.inverted}
|
||||||
|
trigger={
|
||||||
<Icon
|
<Icon
|
||||||
icon="Info"
|
icon="Info"
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
inverted={props.inverted}
|
inverted={props.inverted}
|
||||||
size={props.size == "default" ? "0.85em" : "0.75rem"}
|
size={props.size == "default" ? "0.85em" : "0.75rem"}
|
||||||
/>
|
/>
|
||||||
<KTooltip.Portal>
|
}
|
||||||
<KTooltip.Content
|
>
|
||||||
class={cx("tooltip-content", { inverted: props.inverted })}
|
<Typography
|
||||||
>
|
hierarchy="body"
|
||||||
<Typography
|
size="xs"
|
||||||
hierarchy="body"
|
weight="medium"
|
||||||
size="xs"
|
inverted={!props.inverted}
|
||||||
weight="medium"
|
>
|
||||||
inverted={!props.inverted}
|
{props.tooltip}
|
||||||
>
|
</Typography>
|
||||||
{props.tooltip}
|
</Tooltip>
|
||||||
</Typography>
|
|
||||||
<KTooltip.Arrow />
|
|
||||||
</KTooltip.Content>
|
|
||||||
</KTooltip.Portal>
|
|
||||||
</KTooltip.Trigger>
|
|
||||||
</KTooltip>
|
|
||||||
)}
|
)}
|
||||||
</props.labelComponent>
|
</props.labelComponent>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ div.orienter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
@apply flex-row justify-start;
|
@apply flex-row justify-between gap-0;
|
||||||
|
|
||||||
& > div.form-label {
|
& > div.form-label {
|
||||||
@apply w-1/2 shrink;
|
@apply w-1/2 shrink;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
div.modal-content {
|
div.modal-content {
|
||||||
@apply max-w-[512px];
|
@apply min-w-[320px] max-w-[512px];
|
||||||
@apply rounded-md;
|
@apply rounded-md;
|
||||||
|
|
||||||
/* todo replace with a theme() color */
|
/* 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 border-def-2 rounded-tl-md rounded-tr-md;
|
||||||
@apply border-b-def-3;
|
@apply border-b-def-3;
|
||||||
|
|
||||||
& > .title {
|
& > .modal-title {
|
||||||
@apply mx-auto;
|
@apply mx-auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog as KDialog } from "@kobalte/core/dialog";
|
|||||||
import "./Modal.css";
|
import "./Modal.css";
|
||||||
import { Typography } from "../Typography/Typography";
|
import { Typography } from "../Typography/Typography";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
export interface ModalContext {
|
export interface ModalContext {
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -13,6 +14,8 @@ export interface ModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: (ctx: ModalContext) => JSX.Element;
|
children: (ctx: ModalContext) => JSX.Element;
|
||||||
|
mount?: Node;
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = (props: ModalProps) => {
|
export const Modal = (props: ModalProps) => {
|
||||||
@@ -20,18 +23,33 @@ export const Modal = (props: ModalProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<KDialog id={props.id} open={open()} modal={true}>
|
<KDialog id={props.id} open={open()} modal={true}>
|
||||||
<KDialog.Portal>
|
<KDialog.Portal mount={props.mount}>
|
||||||
<KDialog.Content class="modal-content">
|
<KDialog.Content class={cx("modal-content", props.class)}>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<Typography class="title" hierarchy="label" family="mono" size="xs">
|
<Typography
|
||||||
|
class="modal-title"
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
{props.title}
|
{props.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<KDialog.CloseButton onClick={() => setOpen(false)}>
|
<KDialog.CloseButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Icon icon="Close" size="0.75rem" />
|
<Icon icon="Close" size="0.75rem" />
|
||||||
</KDialog.CloseButton>
|
</KDialog.CloseButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
{props.children({ close: () => setOpen(false) })}
|
{props.children({
|
||||||
|
close: () => {
|
||||||
|
setOpen(false);
|
||||||
|
props.onClose();
|
||||||
|
},
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</KDialog.Content>
|
</KDialog.Content>
|
||||||
</KDialog.Portal>
|
</KDialog.Portal>
|
||||||
|
|||||||
@@ -14,35 +14,35 @@ import { StoryContext } from "@kachurun/storybook-solid-vite";
|
|||||||
|
|
||||||
const sidebarNavProps: SidebarNavProps = {
|
const sidebarNavProps: SidebarNavProps = {
|
||||||
clanLinks: [
|
clanLinks: [
|
||||||
{ label: "Brian's Clan", path: "/clan/1" },
|
{ label: "Brian's Clan", path: "/clans/1" },
|
||||||
{ label: "Dave's Clan", path: "/clan/2" },
|
{ label: "Dave's Clan", path: "/clans/2" },
|
||||||
{ label: "Mic92's Clan", path: "/clan/3" },
|
{ label: "Mic92's Clan", path: "/clans/3" },
|
||||||
],
|
],
|
||||||
clanDetail: {
|
clanDetail: {
|
||||||
label: "Brian's Clan",
|
label: "Brian's Clan",
|
||||||
settingsPath: "/clan/1/settings",
|
settingsPath: "/clans/1/settings",
|
||||||
machines: [
|
machines: [
|
||||||
{
|
{
|
||||||
label: "Backup & Home",
|
label: "Backup & Home",
|
||||||
path: "/clan/1/machine/backup",
|
path: "/clans/1/machine/backup",
|
||||||
serviceCount: 3,
|
serviceCount: 3,
|
||||||
status: "Online",
|
status: "Online",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Raspberry Pi",
|
label: "Raspberry Pi",
|
||||||
path: "/clan/1/machine/pi",
|
path: "/clans/1/machine/pi",
|
||||||
serviceCount: 1,
|
serviceCount: 1,
|
||||||
status: "Offline",
|
status: "Offline",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Mom's Laptop",
|
label: "Mom's Laptop",
|
||||||
path: "/clan/1/machine/moms-laptop",
|
path: "/clans/1/machine/moms-laptop",
|
||||||
serviceCount: 2,
|
serviceCount: 2,
|
||||||
status: "Installed",
|
status: "Installed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dad's Laptop",
|
label: "Dad's Laptop",
|
||||||
path: "/clan/1/machine/dads-laptop",
|
path: "/clans/1/machine/dads-laptop",
|
||||||
serviceCount: 4,
|
serviceCount: 4,
|
||||||
status: "Not Installed",
|
status: "Not Installed",
|
||||||
},
|
},
|
||||||
@@ -52,10 +52,10 @@ const sidebarNavProps: SidebarNavProps = {
|
|||||||
{
|
{
|
||||||
label: "Tools",
|
label: "Tools",
|
||||||
links: [
|
links: [
|
||||||
{ label: "Borgbackup", path: "/clan/1/service/borgbackup" },
|
{ label: "Borgbackup", path: "/clans/1/service/borgbackup" },
|
||||||
{ label: "Syncthing", path: "/clan/1/service/syncthing" },
|
{ label: "Syncthing", path: "/clans/1/service/syncthing" },
|
||||||
{ label: "Mumble", path: "/clan/1/service/mumble" },
|
{ label: "Mumble", path: "/clans/1/service/mumble" },
|
||||||
{ label: "Minecraft", path: "/clan/1/service/minecraft" },
|
{ label: "Minecraft", path: "/clans/1/service/minecraft" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +81,7 @@ const meta: Meta<RouteSectionProps> = {
|
|||||||
component: SidebarNav,
|
component: SidebarNav,
|
||||||
render: (_: never, context: StoryContext<SidebarNavProps>) => {
|
render: (_: never, context: StoryContext<SidebarNavProps>) => {
|
||||||
const history = createMemoryHistory();
|
const history = createMemoryHistory();
|
||||||
history.set({ value: "/clan/1/machine/backup" });
|
history.set({ value: "/clans/1/machine/backup" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style="height: 670px;">
|
<div style="height: 670px;">
|
||||||
@@ -93,7 +93,7 @@ const meta: Meta<RouteSectionProps> = {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Route path="/clan/1/machine/backup" component={(props) => <></>} />
|
<Route path="/clans/1/machine/backup" component={(props) => <></>} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
9
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css
Normal file
9
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx
Normal file
40
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
34
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx
Normal file
34
pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<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.
|
* @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;
|
uuid: string;
|
||||||
result: Promise<OperationResponse<K>>;
|
result: Promise<OperationResponse<K>>;
|
||||||
cancel: () => Promise<void>;
|
cancel: () => Promise<void>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { callApi } from "@/src/hooks/api";
|
import { callApi } from "@/src/hooks/api";
|
||||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||||
import { Params, Navigator } from "@solidjs/router";
|
import { Params, Navigator, useParams } from "@solidjs/router";
|
||||||
|
|
||||||
export const selectClanFolder = async () => {
|
export const selectClanFolder = async () => {
|
||||||
const req = callApi("get_clan_folder", {});
|
const req = callApi("get_clan_folder", {});
|
||||||
@@ -21,9 +21,37 @@ export const selectClanFolder = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const navigateToClan = (navigate: Navigator, uri: string) => {
|
export const navigateToClan = (navigate: Navigator, uri: string) => {
|
||||||
navigate("/clan/" + window.btoa(uri));
|
navigate("/clans/" + window.btoa(uri));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clanURIParam = (params: Params) => {
|
export const clanURIParam = (params: Params) => {
|
||||||
return window.atob(params.clanURI);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { QueryClient } from "@tanstack/solid-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
import { Routes } from "@/src/routes";
|
import { Routes } from "@/src/routes";
|
||||||
import { Router } from "@solidjs/router";
|
import { Router } from "@solidjs/router";
|
||||||
import { Layout } from "@/src/routes/Layout";
|
import { Layout } from "@/src/routes/Layout";
|
||||||
@@ -22,4 +22,11 @@ if (import.meta.env.DEV) {
|
|||||||
await import("solid-devtools");
|
await import("solid-devtools");
|
||||||
}
|
}
|
||||||
|
|
||||||
render(() => <Router root={Layout}>{Routes}</Router>, root!);
|
render(
|
||||||
|
() => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<Router root={Layout}>{Routes}</Router>
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
root!,
|
||||||
|
);
|
||||||
|
|||||||
31
pkgs/clan-app/ui/src/queries/queries.ts
Normal file
31
pkgs/clan-app/ui/src/queries/queries.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
13
pkgs/clan-app/ui/src/routes/Clan/Clan.css
Normal file
13
pkgs/clan-app/ui/src/routes/Clan/Clan.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,231 @@
|
|||||||
import { RouteSectionProps, useParams } from "@solidjs/router";
|
import { RouteSectionProps } from "@solidjs/router";
|
||||||
import { Component } from "solid-js";
|
import { Component, JSX, Show, createSignal } from "solid-js";
|
||||||
import { clanURIParam } from "@/src/hooks/clan";
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
import { CubeScene } from "@/src/scene/cubes";
|
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) => {
|
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||||
const params = useParams();
|
return (
|
||||||
const clanURI = clanURIParam(params);
|
<>
|
||||||
return <CubeScene />;
|
<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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { Component } from "solid-js";
|
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) => (
|
export const Layout: Component<RouteSectionProps> = (props) => {
|
||||||
<div class="size-full h-screen">{props.children}</div>
|
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>;
|
||||||
|
};
|
||||||
|
|||||||
663
pkgs/clan-app/ui/src/routes/Onboarding/Creating.css
Normal file
663
pkgs/clan-app/ui/src/routes/Onboarding/Creating.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
pkgs/clan-app/ui/src/routes/Onboarding/Creating.tsx
Normal file
90
pkgs/clan-app/ui/src/routes/Onboarding/Creating.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -54,8 +54,7 @@ main#welcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > div.container {
|
& > div.container {
|
||||||
@apply flex flex-col items-center justify-evenly gap-y-20;
|
@apply flex flex-col items-center justify-evenly gap-y-20 size-fit;
|
||||||
@apply size-fit;
|
|
||||||
|
|
||||||
& > div.welcome {
|
& > div.welcome {
|
||||||
@apply flex flex-col min-w-80 gap-y-6;
|
@apply flex flex-col min-w-80 gap-y-6;
|
||||||
@@ -66,7 +65,7 @@ main#welcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > div.setup {
|
& > 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;
|
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
|
||||||
|
|
||||||
& > div.header {
|
& > div.header {
|
||||||
|
|||||||
@@ -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 { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||||
import "./Onboarding.css";
|
import "./Onboarding.css";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
|
||||||
import { Divider } from "@/src/components/Divider/Divider";
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
import { Logo } from "@/src/components/Logo/Logo";
|
import { Logo } from "@/src/components/Logo/Logo";
|
||||||
import { navigateToClan, selectClanFolder } from "@/src/hooks/clan";
|
import { navigateToClan, selectClanFolder } from "@/src/hooks/clan";
|
||||||
import { activeClanURI } from "@/src/stores/clan";
|
import { activeClanURI, addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||||
import {
|
import {
|
||||||
createForm,
|
createForm,
|
||||||
FormStore,
|
FormStore,
|
||||||
getError,
|
getError,
|
||||||
getErrors,
|
getErrors,
|
||||||
getValue,
|
getValue,
|
||||||
|
SubmitHandler,
|
||||||
valiForm,
|
valiForm,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
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 { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
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({
|
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.")),
|
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>;
|
type SetupForm = v.InferInput<typeof SetupSchema>;
|
||||||
|
|
||||||
interface backgroundProps {
|
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
|
||||||
state: State;
|
|
||||||
form: FormStore<SetupForm>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const background = (props: backgroundProps) => (
|
|
||||||
<div class="background">
|
<div class="background">
|
||||||
<div class="layer-1" />
|
<div class="layer-1" />
|
||||||
<div class="layer-2" />
|
<div class="layer-2" />
|
||||||
@@ -71,7 +91,11 @@ const background = (props: backgroundProps) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const welcome = (setState: Setter<State>) => {
|
const welcome = (props: {
|
||||||
|
setState: Setter<State>;
|
||||||
|
welcomeError: Accessor<string | undefined>;
|
||||||
|
setWelcomeError: Setter<string | undefined>;
|
||||||
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const selectFolder = async () => {
|
const selectFolder = async () => {
|
||||||
@@ -91,7 +115,23 @@ const welcome = (setState: Setter<State>) => {
|
|||||||
Build your <br />
|
Build your <br />
|
||||||
own darknet
|
own darknet
|
||||||
</Typography>
|
</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
|
Start building
|
||||||
</Button>
|
</Button>
|
||||||
<div class="separator">
|
<div class="separator">
|
||||||
@@ -126,13 +166,89 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
|||||||
|
|
||||||
const [state, setState] = createSignal<State>("welcome");
|
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>({
|
const [setupForm, { Form, Field }] = createForm<SetupForm>({
|
||||||
validate: valiForm(SetupSchema),
|
validate: valiForm(SetupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const metaError = () => {
|
const formError = () => {
|
||||||
const errors = getErrors(setupForm, ["name", "description"]);
|
const formErrors = getErrors(setupForm);
|
||||||
return errors ? errors.name || errors.description : undefined;
|
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 (
|
return (
|
||||||
@@ -140,7 +256,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
|||||||
{background({ form: setupForm, state: state() })}
|
{background({ form: setupForm, state: state() })}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={state() === "welcome"}>{welcome(setState)}</Match>
|
<Match when={state() === "welcome"}>
|
||||||
|
{welcome({
|
||||||
|
setState,
|
||||||
|
welcomeError,
|
||||||
|
setWelcomeError,
|
||||||
|
})}
|
||||||
|
</Match>
|
||||||
|
|
||||||
<Match when={state() === "setup"}>
|
<Match when={state() === "setup"}>
|
||||||
<div class="setup">
|
<div class="setup">
|
||||||
@@ -155,8 +277,16 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
|||||||
Setup
|
Setup
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<Form>
|
<Form onSubmit={onSubmit}>
|
||||||
<Fieldset name="meta" error={metaError()}>
|
{formError() && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
icon="Info"
|
||||||
|
title="Form error"
|
||||||
|
description={formError() || ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Fieldset name="meta">
|
||||||
<Field name="name">
|
<Field name="name">
|
||||||
{(field, input) => (
|
{(field, input) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -195,15 +325,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
|||||||
</Field>
|
</Field>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<Fieldset
|
<Fieldset name="location">
|
||||||
name="location"
|
|
||||||
error={getError(setupForm, "directory")}
|
|
||||||
>
|
|
||||||
<Field name="directory">
|
<Field name="directory">
|
||||||
{(field, input) => (
|
{(field, input) => (
|
||||||
<HostFileInput
|
<HostFileInput
|
||||||
onSelectFile={async () => "test"}
|
onSelectFile={onSelectFile}
|
||||||
{...field}
|
{...field}
|
||||||
|
value={field.value}
|
||||||
label="Select directory"
|
label="Select directory"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
required={true}
|
required={true}
|
||||||
@@ -228,6 +356,10 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
|||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
<Match when={state() === "creating"}>
|
||||||
|
<Creating />
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -8,7 +8,41 @@ export const Routes: RouteDefinition[] = [
|
|||||||
component: Onboarding,
|
component: Onboarding,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/clan/:clanURI",
|
path: "/clans",
|
||||||
component: Clan,
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
15
pkgs/clan-app/ui/src/scene/cubes.css
Normal file
15
pkgs/clan-app/ui/src/scene/cubes.css
Normal 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;
|
||||||
|
}
|
||||||
15
pkgs/clan-app/ui/src/scene/cubes.stories.tsx
Normal file
15
pkgs/clan-app/ui/src/scene/cubes.stories.tsx
Normal 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
45
pkgs/clan-app/ui/src/scene/splash.css
Normal file
45
pkgs/clan-app/ui/src/scene/splash.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
pkgs/clan-app/ui/src/scene/splash.stories.tsx
Normal file
15
pkgs/clan-app/ui/src/scene/splash.stories.tsx
Normal 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: {},
|
||||||
|
};
|
||||||
18
pkgs/clan-app/ui/src/scene/splash.tsx
Normal file
18
pkgs/clan-app/ui/src/scene/splash.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { createStore, produce } from "solid-js/store";
|
import { createStore, produce } from "solid-js/store";
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
import { makePersisted } from "@solid-primitives/storage";
|
||||||
|
|
||||||
interface ClanStoreType {
|
export type SceneData = Record<string, { position: [number, number] }>;
|
||||||
|
|
||||||
|
export interface ClanStoreType {
|
||||||
clanURIs: string[];
|
clanURIs: string[];
|
||||||
activeClanURI?: string;
|
activeClanURI?: string;
|
||||||
|
sceneData: Record<string, SceneData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore] = makePersisted(
|
||||||
createStore<ClanStoreType>({
|
createStore<ClanStoreType>({
|
||||||
clanURIs: [],
|
clanURIs: [],
|
||||||
|
sceneData: {},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "clanStore",
|
name: "clanStore",
|
||||||
@@ -22,7 +26,7 @@ const [store, setStore] = makePersisted(
|
|||||||
* @function
|
* @function
|
||||||
* @returns {string} The URI of the active clan.
|
* @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.
|
* 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.
|
* @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("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.
|
* Removes a specified URI from the clan URI list and updates the active clan URI.
|
||||||
@@ -80,6 +86,7 @@ const removeClanURI = (uri: string) => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
store,
|
store,
|
||||||
|
setStore,
|
||||||
activeClanURI,
|
activeClanURI,
|
||||||
setActiveClanURI,
|
setActiveClanURI,
|
||||||
clanURIs,
|
clanURIs,
|
||||||
|
|||||||
@@ -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";
|
pname = "webview";
|
||||||
version = "nightly";
|
version = "nightly";
|
||||||
|
|
||||||
@@ -8,7 +20,7 @@ pkgs.clangStdenv.mkDerivation {
|
|||||||
# We disallow remote connections from the UI on Linux
|
# We disallow remote connections from the UI on Linux
|
||||||
# TODO: Disallow remote connections on MacOS
|
# TODO: Disallow remote connections on MacOS
|
||||||
|
|
||||||
src = pkgs.fetchFromGitea {
|
src = fetchFromGitea {
|
||||||
domain = "git.clan.lol";
|
domain = "git.clan.lol";
|
||||||
owner = "clan";
|
owner = "clan";
|
||||||
repo = "webview";
|
repo = "webview";
|
||||||
@@ -37,23 +49,19 @@ pkgs.clangStdenv.mkDerivation {
|
|||||||
];
|
];
|
||||||
|
|
||||||
# Dependencies used during the build process, if any
|
# Dependencies used during the build process, if any
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = [
|
||||||
gnumake
|
gnumake
|
||||||
cmake
|
cmake
|
||||||
clang-tools
|
clang-tools
|
||||||
pkg-config
|
pkg-config
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs =
|
buildInputs = lib.optionals stdenv.isLinux [
|
||||||
with pkgs;
|
webkitgtk_6_0
|
||||||
[
|
gtk4
|
||||||
]
|
];
|
||||||
++ pkgs.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)";
|
description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)";
|
||||||
homepage = "https://github.com/webview/webview";
|
homepage = "https://github.com/webview/webview";
|
||||||
license = licenses.mit;
|
license = licenses.mit;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .facts import cli as facts
|
|||||||
from .flash import cli as flash_cli
|
from .flash import cli as flash_cli
|
||||||
from .hyperlink import help_hyperlink
|
from .hyperlink import help_hyperlink
|
||||||
from .machines import cli as machines
|
from .machines import cli as machines
|
||||||
|
from .network import cli as network_cli
|
||||||
from .profiler import profile
|
from .profiler import profile
|
||||||
from .ssh import deploy_info as ssh_cli
|
from .ssh import deploy_info as ssh_cli
|
||||||
from .vars import cli as vars_cli
|
from .vars import cli as vars_cli
|
||||||
@@ -428,6 +429,26 @@ Examples:
|
|||||||
)
|
)
|
||||||
select.register_parser(parser_select)
|
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(
|
parser_state = subparsers.add_parser(
|
||||||
"state",
|
"state",
|
||||||
aliases=["st"],
|
aliases=["st"],
|
||||||
@@ -462,7 +483,7 @@ For more detailed information, visit: {help_hyperlink("getting-started", "https:
|
|||||||
state.register_parser(parser_state)
|
state.register_parser(parser_state)
|
||||||
|
|
||||||
if argcomplete:
|
if argcomplete:
|
||||||
argcomplete.autocomplete(parser, exclude=["morph"])
|
argcomplete.autocomplete(parser, exclude=["morph", "network", "net"])
|
||||||
|
|
||||||
register_common_flags(parser)
|
register_common_flags(parser)
|
||||||
|
|
||||||
|
|||||||
72
pkgs/clan-cli/clan_cli/network/cli.py
Normal file
72
pkgs/clan-cli/clan_cli/network/cli.py
Normal 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)
|
||||||
64
pkgs/clan-cli/clan_cli/network/list.py
Normal file
64
pkgs/clan-cli/clan_cli/network/list.py
Normal 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)
|
||||||
21
pkgs/clan-cli/clan_cli/network/overview.py
Normal file
21
pkgs/clan-cli/clan_cli/network/overview.py
Normal 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)
|
||||||
67
pkgs/clan-cli/clan_cli/network/ping.py
Normal file
67
pkgs/clan-cli/clan_cli/network/ping.py
Normal 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)
|
||||||
98
pkgs/clan-cli/clan_lib/import_utils/__init__.py
Normal file
98
pkgs/clan-cli/clan_lib/import_utils/__init__.py
Normal 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))
|
||||||
145
pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py
Normal file
145
pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py
Normal 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),
|
||||||
|
)
|
||||||
9
pkgs/clan-cli/clan_lib/network/direct.py
Normal file
9
pkgs/clan-cli/clan_lib/network/direct.py
Normal 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
|
||||||
156
pkgs/clan-cli/clan_lib/network/network.py
Normal file
156
pkgs/clan-cli/clan_lib/network/network.py
Normal 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
|
||||||
106
pkgs/clan-cli/clan_lib/network/network_test.py
Normal file
106
pkgs/clan-cli/clan_lib/network/network_test.py
Normal 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
|
||||||
20
pkgs/clan-cli/clan_lib/network/tor.py
Normal file
20
pkgs/clan-cli/clan_lib/network/tor.py
Normal 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
|
||||||
@@ -139,7 +139,7 @@ class InventoryStore:
|
|||||||
def _load_merged_inventory(self) -> InventorySnapshot:
|
def _load_merged_inventory(self) -> InventorySnapshot:
|
||||||
"""
|
"""
|
||||||
Loads the evaluated inventory.
|
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.
|
Evaluates clanInternals.inventoryClass.inventory with nix. Which is performant.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from clan_cli.cli import create_parser
|
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
|
@dataclass
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{ self, clan-core, ... }:
|
{
|
||||||
|
self,
|
||||||
|
clan-core,
|
||||||
|
nixpkgs,
|
||||||
|
...
|
||||||
|
}@inputs:
|
||||||
let
|
let
|
||||||
# Usage see: https://docs.clan.lol
|
# Usage see: https://docs.clan.lol
|
||||||
clan = clan-core.lib.clan {
|
clan = clan-core.lib.clan {
|
||||||
inherit self;
|
inherit self;
|
||||||
imports = [ ./clan.nix ];
|
imports = [ ./clan.nix ];
|
||||||
|
specialArgs = { inherit inputs; };
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
# Add the Clan cli tool to the dev shell.
|
# Add the Clan cli tool to the dev shell.
|
||||||
# Use "nix develop" to enter the dev shell.
|
# Use "nix develop" to enter the dev shell.
|
||||||
devShells =
|
devShells =
|
||||||
clan-core.inputs.nixpkgs.lib.genAttrs
|
nixpkgs.lib.genAttrs
|
||||||
[
|
[
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
|
|||||||
Reference in New Issue
Block a user