Compare commits
86 Commits
push-uymll
...
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
|
||||
set -euo pipefail
|
||||
set -eu
|
||||
|
||||
# Required environment variables:
|
||||
# - CI_BOT_TOKEN: Gitea bot token for authentication
|
||||
@@ -8,22 +9,22 @@ set -euo pipefail
|
||||
# - PR_TITLE: Title of the pull request
|
||||
# - PR_BODY: Body/description of the pull request
|
||||
|
||||
if [[ -z "${CI_BOT_TOKEN:-}" ]]; then
|
||||
if [ -z "${CI_BOT_TOKEN:-}" ]; then
|
||||
echo "Error: CI_BOT_TOKEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_BRANCH:-}" ]]; then
|
||||
if [ -z "${PR_BRANCH:-}" ]; then
|
||||
echo "Error: PR_BRANCH is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_TITLE:-}" ]]; then
|
||||
if [ -z "${PR_TITLE:-}" ]; then
|
||||
echo "Error: PR_TITLE is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_BODY:-}" ]]; then
|
||||
if [ -z "${PR_BODY:-}" ]; then
|
||||
echo "Error: PR_BODY is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -43,9 +44,12 @@ resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
|
||||
}" \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
|
||||
|
||||
pr_number=$(echo "$resp" | jq -r '.number')
|
||||
if ! pr_number=$(echo "$resp" | jq -r '.number'); then
|
||||
echo "Error parsing response from pull request creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$pr_number" == "null" ]]; then
|
||||
if [ "$pr_number" = "null" ]; then
|
||||
echo "Error creating pull request:" >&2
|
||||
echo "$resp" | jq . >&2
|
||||
exit 1
|
||||
@@ -64,12 +68,15 @@ while true; do
|
||||
"delete_branch_after_merge": true
|
||||
}' \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
|
||||
msg=$(echo "$resp" | jq -r '.message')
|
||||
if [[ "$msg" != "Please try again later" ]]; then
|
||||
if ! msg=$(echo "$resp" | jq -r '.message'); then
|
||||
echo "Error parsing merge response" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$msg" != "Please try again later" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Retrying in 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Pull request #$pr_number merge initiated"
|
||||
echo "Pull request #$pr_number merge initiated"
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
recommendedOptimisation = lib.mkDefault true;
|
||||
recommendedProxySettings = lib.mkDefault true;
|
||||
recommendedTlsSettings = lib.mkDefault true;
|
||||
recommendedZstdSettings = lib.mkDefault true;
|
||||
|
||||
# Nginx sends all the access logs to /var/log/nginx/access.log by default.
|
||||
# instead of going to the journal!
|
||||
|
||||
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 =
|
||||
{ settings, ... }:
|
||||
{ instanceName, settings, ... }:
|
||||
{
|
||||
nixosModule =
|
||||
{ pkgs, config, ... }:
|
||||
@@ -86,7 +86,7 @@ in
|
||||
|
||||
# service to generate the environment file containing all secrets, as
|
||||
# expected by the nixos NetworkManager-ensure-profile service
|
||||
systemd.services.NetworkManager-setup-secrets = {
|
||||
systemd.services."NetworkManager-setup-secrets-${instanceName}" = {
|
||||
description = "Generate wifi secrets for NetworkManager";
|
||||
requiredBy = [ "NetworkManager-ensure-profiles.service" ];
|
||||
partOf = [ "NetworkManager-ensure-profiles.service" ];
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
inventory = {
|
||||
|
||||
machines.test = { };
|
||||
machines.second = { };
|
||||
|
||||
instances = {
|
||||
wg-test-all = {
|
||||
module.name = "@clan/wifi";
|
||||
module.input = "self";
|
||||
roles.default.tags.all = { };
|
||||
roles.default.settings.networks.all = { };
|
||||
};
|
||||
|
||||
wg-test-one = {
|
||||
module.name = "@clan/wifi";
|
||||
module.input = "self";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
buildClanOptions = self'.legacyPackages.clan-internals-docs;
|
||||
clanOptions = self'.legacyPackages.clan-internals-docs;
|
||||
# Simply evaluated options (JSON)
|
||||
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
|
||||
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
|
||||
@@ -99,7 +99,7 @@
|
||||
# Frontmatter format for clanModules
|
||||
export CLAN_MODULES_FRONTMATTER_DOCS=${clanModulesFrontmatter}/share/doc/nixos/options.json
|
||||
|
||||
export BUILD_CLAN_PATH=${buildClanOptions}/share/doc/nixos/options.json
|
||||
export BUILD_CLAN_PATH=${clanOptions}/share/doc/nixos/options.json
|
||||
|
||||
mkdir $out
|
||||
|
||||
|
||||
@@ -465,6 +465,10 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
|
||||
service_links: dict[str, dict[str, dict[str, Any]]] = json.load(f3)
|
||||
|
||||
for module_name, module_info in service_links.items():
|
||||
# Skip specific modules that are not ready for documentation
|
||||
if module_name in ["internet", "tor"]:
|
||||
continue
|
||||
|
||||
output = f"# {module_name}\n\n"
|
||||
# output += f"`clan.modules.{module_name}`\n"
|
||||
output += f"*{module_info['manifest']['description']}*\n"
|
||||
|
||||
@@ -35,6 +35,37 @@ services = {
|
||||
};
|
||||
```
|
||||
|
||||
### Complex Example: Multi-service Setup
|
||||
|
||||
```nix
|
||||
# Old format
|
||||
services = {
|
||||
borgbackup.production = {
|
||||
roles.server.machines = [ "backup-server" ];
|
||||
roles.server.config = {
|
||||
directory = "/var/backup/borg";
|
||||
};
|
||||
roles.client.tags = [ "backup" ];
|
||||
roles.client.extraModules = [ "nixosModules/borgbackup.nix" ];
|
||||
};
|
||||
|
||||
zerotier.company-network = {
|
||||
roles.controller.machines = [ "network-controller" ];
|
||||
roles.moon.machines = [ "moon-1" "moon-2" ];
|
||||
roles.peer.tags = [ "nixos" ];
|
||||
};
|
||||
|
||||
sshd.internal = {
|
||||
roles.server.tags = [ "nixos" ];
|
||||
roles.client.tags = [ "nixos" ];
|
||||
config.certificate.searchDomains = [
|
||||
"internal.example.com"
|
||||
"vpn.example.com"
|
||||
];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ After: New `instances` Definition with `clanServices`
|
||||
@@ -70,6 +101,56 @@ instances = {
|
||||
};
|
||||
```
|
||||
|
||||
### Complex Example Migrated
|
||||
|
||||
```nix
|
||||
# New format
|
||||
instances = {
|
||||
borgbackup-production = {
|
||||
module = {
|
||||
name = "borgbackup";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.server.machines."backup-server" = { };
|
||||
roles.server.settings = {
|
||||
directory = "/var/backup/borg";
|
||||
};
|
||||
roles.client.tags.backup = { };
|
||||
roles.client.extraModules = [ ../nixosModules/borgbackup.nix ];
|
||||
};
|
||||
|
||||
zerotier-company-network = {
|
||||
module = {
|
||||
name = "zerotier";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.controller.machines."network-controller" = { };
|
||||
roles.moon.machines."moon-1".settings = {
|
||||
stableEndpoints = [ "10.0.0.1" "2001:db8::1" ];
|
||||
};
|
||||
roles.moon.machines."moon-2".settings = {
|
||||
stableEndpoints = [ "10.0.0.2" "2001:db8::2" ];
|
||||
};
|
||||
roles.peer.tags.nixos = { };
|
||||
};
|
||||
|
||||
sshd-internal = {
|
||||
module = {
|
||||
name = "sshd";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.server.tags.nixos = { };
|
||||
roles.client.tags.nixos = { };
|
||||
roles.client.settings = {
|
||||
certificate.searchDomains = [
|
||||
"internal.example.com"
|
||||
"vpn.example.com"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Steps to Migrate
|
||||
@@ -131,6 +212,33 @@ roles.default.machines."test-inventory-machine".settings = {
|
||||
};
|
||||
```
|
||||
|
||||
### Important Type Changes
|
||||
|
||||
The new `instances` format uses **attribute sets** instead of **lists** for tags and machines:
|
||||
|
||||
```nix
|
||||
# ❌ Old format (lists)
|
||||
roles.client.tags = [ "backup" ];
|
||||
roles.server.machines = [ "blob64" ];
|
||||
|
||||
# ✅ New format (attribute sets)
|
||||
roles.client.tags.backup = { };
|
||||
roles.server.machines.blob64 = { };
|
||||
```
|
||||
|
||||
### Handling Multiple Machines/Tags
|
||||
|
||||
When you need to assign multiple machines or tags to a role:
|
||||
|
||||
```nix
|
||||
# ❌ Old format
|
||||
roles.moon.machines = [ "eva" "eve" ];
|
||||
|
||||
# ✅ New format - each machine gets its own attribute
|
||||
roles.moon.machines.eva = { };
|
||||
roles.moon.machines.eve = { };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
!!! Warning
|
||||
@@ -138,8 +246,89 @@ roles.default.machines."test-inventory-machine".settings = {
|
||||
* `inventory.services` is no longer recommended; use `inventory.instances` instead.
|
||||
* Module authors should begin exporting service modules under the `clan.modules` attribute of their flake.
|
||||
|
||||
## Troubleshooting Common Migration Errors
|
||||
|
||||
### Error: "not of type `attribute set of (submodule)`"
|
||||
|
||||
This error occurs when using lists instead of attribute sets for tags or machines:
|
||||
|
||||
```
|
||||
error: A definition for option `flake.clan.inventory.instances.borgbackup-blob64.roles.client.tags' is not of type `attribute set of (submodule)'.
|
||||
```
|
||||
|
||||
**Solution**: Convert lists to attribute sets as shown in the "Important Type Changes" section above.
|
||||
|
||||
### Error: "unsupported attribute `module`"
|
||||
|
||||
This error indicates the module structure is incorrect:
|
||||
|
||||
```
|
||||
error: Module ':anon-4:anon-1' has an unsupported attribute `module'.
|
||||
```
|
||||
|
||||
**Solution**: Ensure the `module` attribute has exactly two fields: `name` and `input`.
|
||||
|
||||
### Error: "attribute 'pkgs' missing"
|
||||
|
||||
This suggests the instance configuration is trying to use imports incorrectly:
|
||||
|
||||
```
|
||||
error: attribute 'pkgs' missing
|
||||
```
|
||||
|
||||
**Solution**: Use the `module = { name = "..."; input = "..."; }` format instead of `imports`.
|
||||
|
||||
### Removed Features
|
||||
|
||||
The following features from the old `services` format are no longer supported in `instances`:
|
||||
|
||||
- Top-level `config` attribute (use `roles.<role>.settings` instead)
|
||||
- Direct module imports (use the `module` declaration instead)
|
||||
|
||||
### extraModules Support
|
||||
|
||||
The `extraModules` attribute is still supported in the new instances format! The key change is how modules are specified:
|
||||
|
||||
**Old format (string paths relative to clan root):**
|
||||
```nix
|
||||
roles.client.extraModules = [ "nixosModules/borgbackup.nix" ];
|
||||
```
|
||||
|
||||
**New format (NixOS modules):**
|
||||
```nix
|
||||
# Direct module reference
|
||||
roles.client.extraModules = [ ../nixosModules/borgbackup.nix ];
|
||||
|
||||
# Or using self
|
||||
roles.client.extraModules = [ self.nixosModules.borgbackup ];
|
||||
|
||||
# Or inline module definition
|
||||
roles.client.extraModules = [
|
||||
{ config, ... }: {
|
||||
# Your module configuration here
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
The `extraModules` now expects actual **NixOS modules** rather than string paths. This provides better type checking and more flexibility in how modules are specified.
|
||||
|
||||
**Alternative: Using @clan/importer**
|
||||
|
||||
For scenarios where you need to import modules with specific tag-based targeting, you can also use the dedicated `@clan/importer` service:
|
||||
|
||||
```nix
|
||||
instances = {
|
||||
my-importer = {
|
||||
module.name = "@clan/importer";
|
||||
module.input = "clan-core";
|
||||
roles.default.tags.my-tag = { };
|
||||
roles.default.extraModules = [ self.nixosModules.myModule ];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Further reference
|
||||
|
||||
* [Authoring a 'clan.service' module](../authoring/clanServices/index.md)
|
||||
* [ClanServices](../clanServices.md)
|
||||
* [Inventory Reference](../../reference/nix-api/inventory.md)
|
||||
* [Inventory Reference](../../reference/nix-api/inventory.md)
|
||||
|
||||
20
flake.lock
generated
20
flake.lock
generated
@@ -13,11 +13,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752589312,
|
||||
"narHash": "sha256-BafZOenlzMYdumG12AzgVLhEVu+GcEa8nYNDSIYe1U0=",
|
||||
"rev": "496bbf05a2aa7b061ef464254db5804d1c6f45b4",
|
||||
"lastModified": 1753067306,
|
||||
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
|
||||
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/496bbf05a2aa7b061ef464254db5804d1c6f45b4.tar.gz"
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/18dfd42bdb2cfff510b8c74206005f733e38d8b9.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -31,11 +31,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752541678,
|
||||
"narHash": "sha256-dyhGzkld6jPqnT/UfGV2oqe7tYn7hppAqFvF3GZTyXY=",
|
||||
"lastModified": 1752718651,
|
||||
"narHash": "sha256-PkaR0qmyP9q/MDN3uYa+RLeBA0PjvEQiM0rTDDBXkL8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "2bf3421f7fed5c84d9392b62dcb9d76ef09796a7",
|
||||
"rev": "d5ad4485e6f2edcc06751df65c5e16572877db88",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -181,11 +181,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752055615,
|
||||
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=",
|
||||
"lastModified": 1753006367,
|
||||
"narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9",
|
||||
"rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
inputs = {
|
||||
flake-parts.follows = "flake-parts";
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
systems.follows = "systems";
|
||||
treefmt-nix.follows = "treefmt-nix";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ lib.fix (
|
||||
{
|
||||
|
||||
inherit (buildClanLib)
|
||||
buildClan
|
||||
clan
|
||||
;
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,87 @@ in
|
||||
internal = true;
|
||||
visible = false;
|
||||
type = types.deferredModule;
|
||||
default = { };
|
||||
default = {
|
||||
options.networking = lib.mkOption {
|
||||
default = null;
|
||||
type = lib.types.nullOr (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
priority = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1000;
|
||||
description = ''
|
||||
priority with which this network should be tried.
|
||||
higher priority means it gets used earlier in the chain
|
||||
'';
|
||||
};
|
||||
module = lib.mkOption {
|
||||
# type = lib.types.enum [
|
||||
# "clan_lib.network.direct"
|
||||
# "clan_lib.network.tor"
|
||||
# ];
|
||||
type = lib.types.str;
|
||||
default = "clan_lib.network.direct";
|
||||
description = ''
|
||||
the technology this network uses to connect to the target
|
||||
This is used for userspace networking with socks proxies.
|
||||
'';
|
||||
};
|
||||
# should we call this machines? hosts?
|
||||
peers = lib.mkOption {
|
||||
# <name>
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = name;
|
||||
};
|
||||
SSHOptions = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
};
|
||||
host = lib.mkOption {
|
||||
description = '''';
|
||||
type = lib.types.attrTag {
|
||||
plain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
a plain value, which can be read directly from the config
|
||||
'';
|
||||
};
|
||||
var = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
machine = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "jon";
|
||||
};
|
||||
generator = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "tor-ssh";
|
||||
};
|
||||
file = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "hostname";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
A module that is used to define the module of flake level exports -
|
||||
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
clan-core,
|
||||
...
|
||||
}:
|
||||
rec {
|
||||
buildClan =
|
||||
# TODO: Once all templates and docs are migrated add: lib.warn "'buildClan' is deprecated. Use 'clan-core.lib.clan' instead"
|
||||
module: (clan module).config;
|
||||
|
||||
{
|
||||
clan =
|
||||
{
|
||||
self ? lib.warn "Argument: 'self' must be set" null, # Reference to the current flake
|
||||
|
||||
@@ -48,6 +48,7 @@ in
|
||||
{
|
||||
options = {
|
||||
instances = lib.mkOption {
|
||||
default = { };
|
||||
# instances.<instanceName>...
|
||||
type = types.attrsOf (submoduleWith {
|
||||
modules = [
|
||||
@@ -57,6 +58,7 @@ in
|
||||
};
|
||||
# instances.<machineName>...
|
||||
machines = lib.mkOption {
|
||||
default = { };
|
||||
type = types.attrsOf (submoduleWith {
|
||||
modules = [
|
||||
config.exportsModule
|
||||
|
||||
@@ -29,10 +29,7 @@ def _get_lib_names() -> list[str]:
|
||||
msg = f"Unsupported architecture: {machine}"
|
||||
raise RuntimeError(msg)
|
||||
if system == "darwin":
|
||||
if machine == "arm64":
|
||||
return ["libwebview.dylib"]
|
||||
msg = "Not supported"
|
||||
raise RuntimeError(msg)
|
||||
return ["libwebview.dylib"]
|
||||
# linux
|
||||
return ["libwebview.so"]
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ buildNpmPackage (_finalAttrs: {
|
||||
mkdir -p api
|
||||
cp -r ${clan-ts-api}/* api
|
||||
cp -r ${fonts} ".fonts"
|
||||
|
||||
# only needed for the next couple weeks to make sure this file doesn't make it back into the git history
|
||||
if [[ -f "${./ui}/src/routes/Onboarding/background.jpg" ]]; then
|
||||
echo "background.jpg found, exiting"
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
|
||||
# todo figure out why this fails only inside of Nix
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { StorybookConfig } from "@kachurun/storybook-solid-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@kachurun/storybook-solid-vite",
|
||||
stories: ["../src/components/**/*.mdx", "../src/components/**/*.stories.tsx"],
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-docs",
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
& > span.typography {
|
||||
@apply max-w-full overflow-hidden whitespace-nowrap text-ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/* button group */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
import { Checkbox } from "@/src/components/Form/Checkbox";
|
||||
import { FieldProps } from "./Field";
|
||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||
|
||||
const FieldsetExamples = (props: FieldsetProps) => (
|
||||
<div class="flex flex-col gap-8">
|
||||
@@ -26,7 +27,7 @@ const meta = {
|
||||
<div
|
||||
class={cx({
|
||||
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||
"w-[1024px]": context.args.orientation == "horizontal",
|
||||
"w-[512px]": context.args.orientation == "horizontal",
|
||||
"bg-inv-acc-3": context.args.inverted,
|
||||
})}
|
||||
>
|
||||
@@ -63,6 +64,11 @@ export const Default: Story = {
|
||||
label="Bio"
|
||||
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
|
||||
/>
|
||||
<HostFileInput
|
||||
{...props}
|
||||
label="Profile pic"
|
||||
onSelectFile={async () => "/home/foo/bar/baz/fizz/buzz/bla/bizz"}
|
||||
/>
|
||||
<Checkbox {...props} label="Accept Terms" required={true} />
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
div.form-field.host-file {
|
||||
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 = {
|
||||
args: {
|
||||
onSelectFile: async () => {
|
||||
return "/home/bob/clans/my-clan";
|
||||
return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz";
|
||||
},
|
||||
input: {
|
||||
placeholder: "e.g. 11/06/89",
|
||||
|
||||
@@ -12,6 +12,8 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import { createSignal } from "solid-js";
|
||||
import { Tooltip } from "@kobalte/core/tooltip";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
|
||||
export type HostFileInputProps = FieldProps &
|
||||
TextFieldRootProps & {
|
||||
@@ -20,10 +22,21 @@ export type HostFileInputProps = FieldProps &
|
||||
};
|
||||
|
||||
export const HostFileInput = (props: HostFileInputProps) => {
|
||||
const [value, setValue] = createSignal<string | undefined>(undefined);
|
||||
const [value, setValue] = createSignal<string>(props.value || "");
|
||||
|
||||
let actualInputElement: HTMLInputElement | undefined;
|
||||
|
||||
const selectFile = async () => {
|
||||
setValue(await props.onSelectFile());
|
||||
try {
|
||||
console.log("selecting file", props.onSelectFile);
|
||||
setValue(await props.onSelectFile());
|
||||
actualInputElement?.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error selecting file", error);
|
||||
// todo work out how to display the error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -33,26 +46,65 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...props}
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
>
|
||||
<Orienter orientation={props.orientation} align={"start"}>
|
||||
<Orienter
|
||||
orientation={props.orientation}
|
||||
align={props.orientation == "horizontal" ? "center" : "start"}
|
||||
>
|
||||
<Label
|
||||
labelComponent={TextField.Label}
|
||||
descriptionComponent={TextField.Description}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<TextField.Input {...props.input} hidden={true} />
|
||||
<TextField.Input
|
||||
{...props.input}
|
||||
hidden={true}
|
||||
value={value()}
|
||||
ref={(el: HTMLInputElement) => {
|
||||
actualInputElement = el; // Capture for local use
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
size={props.size}
|
||||
startIcon="Folder"
|
||||
onClick={selectFile}
|
||||
>
|
||||
{value() ? value() : "No Selection"}
|
||||
</Button>
|
||||
{!value() && (
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
size={props.size}
|
||||
startIcon="Folder"
|
||||
onClick={selectFile}
|
||||
disabled={props.disabled || props.readOnly}
|
||||
>
|
||||
No Selection
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{value() && (
|
||||
<Tooltip placement="top">
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="tooltip-content">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={!props.inverted}
|
||||
>
|
||||
{value()}
|
||||
</Typography>
|
||||
<Tooltip.Arrow />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
<Tooltip.Trigger
|
||||
as={Button}
|
||||
hierarchy="secondary"
|
||||
size={props.size}
|
||||
startIcon="Folder"
|
||||
onClick={selectFile}
|
||||
disabled={props.disabled || props.readOnly}
|
||||
>
|
||||
{value()}
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Orienter>
|
||||
</TextField>
|
||||
);
|
||||
|
||||
@@ -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 { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Tooltip as KTooltip } from "@kobalte/core/tooltip";
|
||||
import { Tooltip } from "@/src/components/Tooltip/Tooltip";
|
||||
import Icon from "@/src/components/Icon/Icon";
|
||||
import { TextField } from "@kobalte/core/text-field";
|
||||
import { Checkbox } from "@kobalte/core/checkbox";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import "./Label.css";
|
||||
import cx from "classnames";
|
||||
|
||||
export type Size = "default" | "s";
|
||||
|
||||
@@ -49,31 +48,27 @@ export const Label = (props: LabelProps) => {
|
||||
{props.label}
|
||||
</Typography>
|
||||
{props.tooltip && (
|
||||
<KTooltip placement="top">
|
||||
<KTooltip.Trigger>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inverted={props.inverted}
|
||||
trigger={
|
||||
<Icon
|
||||
icon="Info"
|
||||
color="tertiary"
|
||||
inverted={props.inverted}
|
||||
size={props.size == "default" ? "0.85em" : "0.75rem"}
|
||||
/>
|
||||
<KTooltip.Portal>
|
||||
<KTooltip.Content
|
||||
class={cx("tooltip-content", { inverted: props.inverted })}
|
||||
>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={!props.inverted}
|
||||
>
|
||||
{props.tooltip}
|
||||
</Typography>
|
||||
<KTooltip.Arrow />
|
||||
</KTooltip.Content>
|
||||
</KTooltip.Portal>
|
||||
</KTooltip.Trigger>
|
||||
</KTooltip>
|
||||
}
|
||||
>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted={!props.inverted}
|
||||
>
|
||||
{props.tooltip}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</props.labelComponent>
|
||||
{props.description && (
|
||||
|
||||
@@ -5,7 +5,7 @@ div.orienter {
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
@apply flex-row justify-start;
|
||||
@apply flex-row justify-between gap-0;
|
||||
|
||||
& > div.form-label {
|
||||
@apply w-1/2 shrink;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
div.modal-content {
|
||||
@apply max-w-[512px];
|
||||
@apply min-w-[320px] max-w-[512px];
|
||||
@apply rounded-md;
|
||||
|
||||
/* todo replace with a theme() color */
|
||||
@@ -12,7 +12,7 @@ div.modal-content {
|
||||
@apply border border-def-2 rounded-tl-md rounded-tr-md;
|
||||
@apply border-b-def-3;
|
||||
|
||||
& > .title {
|
||||
& > .modal-title {
|
||||
@apply mx-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog as KDialog } from "@kobalte/core/dialog";
|
||||
import "./Modal.css";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import Icon from "../Icon/Icon";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface ModalContext {
|
||||
close(): void;
|
||||
@@ -13,6 +14,8 @@ export interface ModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: (ctx: ModalContext) => JSX.Element;
|
||||
mount?: Node;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const Modal = (props: ModalProps) => {
|
||||
@@ -20,18 +23,33 @@ export const Modal = (props: ModalProps) => {
|
||||
|
||||
return (
|
||||
<KDialog id={props.id} open={open()} modal={true}>
|
||||
<KDialog.Portal>
|
||||
<KDialog.Content class="modal-content">
|
||||
<KDialog.Portal mount={props.mount}>
|
||||
<KDialog.Content class={cx("modal-content", props.class)}>
|
||||
<div class="header">
|
||||
<Typography class="title" hierarchy="label" family="mono" size="xs">
|
||||
<Typography
|
||||
class="modal-title"
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<KDialog.CloseButton onClick={() => setOpen(false)}>
|
||||
<KDialog.CloseButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
<Icon icon="Close" size="0.75rem" />
|
||||
</KDialog.CloseButton>
|
||||
</div>
|
||||
<div class="body">
|
||||
{props.children({ close: () => setOpen(false) })}
|
||||
{props.children({
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
props.onClose();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</KDialog.Content>
|
||||
</KDialog.Portal>
|
||||
|
||||
@@ -14,35 +14,35 @@ import { StoryContext } from "@kachurun/storybook-solid-vite";
|
||||
|
||||
const sidebarNavProps: SidebarNavProps = {
|
||||
clanLinks: [
|
||||
{ label: "Brian's Clan", path: "/clan/1" },
|
||||
{ label: "Dave's Clan", path: "/clan/2" },
|
||||
{ label: "Mic92's Clan", path: "/clan/3" },
|
||||
{ label: "Brian's Clan", path: "/clans/1" },
|
||||
{ label: "Dave's Clan", path: "/clans/2" },
|
||||
{ label: "Mic92's Clan", path: "/clans/3" },
|
||||
],
|
||||
clanDetail: {
|
||||
label: "Brian's Clan",
|
||||
settingsPath: "/clan/1/settings",
|
||||
settingsPath: "/clans/1/settings",
|
||||
machines: [
|
||||
{
|
||||
label: "Backup & Home",
|
||||
path: "/clan/1/machine/backup",
|
||||
path: "/clans/1/machine/backup",
|
||||
serviceCount: 3,
|
||||
status: "Online",
|
||||
},
|
||||
{
|
||||
label: "Raspberry Pi",
|
||||
path: "/clan/1/machine/pi",
|
||||
path: "/clans/1/machine/pi",
|
||||
serviceCount: 1,
|
||||
status: "Offline",
|
||||
},
|
||||
{
|
||||
label: "Mom's Laptop",
|
||||
path: "/clan/1/machine/moms-laptop",
|
||||
path: "/clans/1/machine/moms-laptop",
|
||||
serviceCount: 2,
|
||||
status: "Installed",
|
||||
},
|
||||
{
|
||||
label: "Dad's Laptop",
|
||||
path: "/clan/1/machine/dads-laptop",
|
||||
path: "/clans/1/machine/dads-laptop",
|
||||
serviceCount: 4,
|
||||
status: "Not Installed",
|
||||
},
|
||||
@@ -52,10 +52,10 @@ const sidebarNavProps: SidebarNavProps = {
|
||||
{
|
||||
label: "Tools",
|
||||
links: [
|
||||
{ label: "Borgbackup", path: "/clan/1/service/borgbackup" },
|
||||
{ label: "Syncthing", path: "/clan/1/service/syncthing" },
|
||||
{ label: "Mumble", path: "/clan/1/service/mumble" },
|
||||
{ label: "Minecraft", path: "/clan/1/service/minecraft" },
|
||||
{ label: "Borgbackup", path: "/clans/1/service/borgbackup" },
|
||||
{ label: "Syncthing", path: "/clans/1/service/syncthing" },
|
||||
{ label: "Mumble", path: "/clans/1/service/mumble" },
|
||||
{ label: "Minecraft", path: "/clans/1/service/minecraft" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -81,7 +81,7 @@ const meta: Meta<RouteSectionProps> = {
|
||||
component: SidebarNav,
|
||||
render: (_: never, context: StoryContext<SidebarNavProps>) => {
|
||||
const history = createMemoryHistory();
|
||||
history.set({ value: "/clan/1/machine/backup" });
|
||||
history.set({ value: "/clans/1/machine/backup" });
|
||||
|
||||
return (
|
||||
<div style="height: 670px;">
|
||||
@@ -93,7 +93,7 @@ const meta: Meta<RouteSectionProps> = {
|
||||
</Suspense>
|
||||
)}
|
||||
>
|
||||
<Route path="/clan/1/machine/backup" component={(props) => <></>} />
|
||||
<Route path="/clans/1/machine/backup" component={(props) => <></>} />
|
||||
</MemoryRouter>
|
||||
</div>
|
||||
);
|
||||
|
||||
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<void>} cancel - A function to cancel the API call, returning a promise that resolves when cancellation is completed.
|
||||
*/
|
||||
interface ApiCall<K extends OperationNames> {
|
||||
export interface ApiCall<K extends OperationNames> {
|
||||
uuid: string;
|
||||
result: Promise<OperationResponse<K>>;
|
||||
cancel: () => Promise<void>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Params, Navigator } from "@solidjs/router";
|
||||
import { Params, Navigator, useParams } from "@solidjs/router";
|
||||
|
||||
export const selectClanFolder = async () => {
|
||||
const req = callApi("get_clan_folder", {});
|
||||
@@ -21,9 +21,37 @@ export const selectClanFolder = async () => {
|
||||
};
|
||||
|
||||
export const navigateToClan = (navigate: Navigator, uri: string) => {
|
||||
navigate("/clan/" + window.btoa(uri));
|
||||
navigate("/clans/" + window.btoa(uri));
|
||||
};
|
||||
|
||||
export const clanURIParam = (params: Params) => {
|
||||
return window.atob(params.clanURI);
|
||||
};
|
||||
|
||||
export function useClanURI(opts: { force: true }): string;
|
||||
export function useClanURI(opts: { force: boolean }): string | null;
|
||||
export function useClanURI(
|
||||
opts: { force: boolean } = { force: false },
|
||||
): string | null {
|
||||
const maybe = () => {
|
||||
const params = useParams();
|
||||
if (!params.clanURI) {
|
||||
return null;
|
||||
}
|
||||
const clanURI = clanURIParam(params);
|
||||
if (!clanURI) {
|
||||
throw new Error(
|
||||
"Could not decode clan URI from params: " + params.clanURI,
|
||||
);
|
||||
}
|
||||
return clanURI;
|
||||
};
|
||||
|
||||
const uri = maybe();
|
||||
if (!uri && opts.force) {
|
||||
throw new Error(
|
||||
"ClanURI is not set. Use this function only within contexts, where clanURI is guaranteed to have been set.",
|
||||
);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import "./index.css";
|
||||
import { QueryClient } from "@tanstack/solid-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import { Routes } from "@/src/routes";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { Layout } from "@/src/routes/Layout";
|
||||
@@ -22,4 +22,11 @@ if (import.meta.env.DEV) {
|
||||
await import("solid-devtools");
|
||||
}
|
||||
|
||||
render(() => <Router root={Layout}>{Routes}</Router>, root!);
|
||||
render(
|
||||
() => (
|
||||
<QueryClientProvider client={client}>
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
root!,
|
||||
);
|
||||
|
||||
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 { Component } from "solid-js";
|
||||
import { clanURIParam } from "@/src/hooks/clan";
|
||||
import { RouteSectionProps } from "@solidjs/router";
|
||||
import { Component, JSX, Show, createSignal } from "solid-js";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { store, setStore } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Splash } from "@/src/scene/splash";
|
||||
import cx from "classnames";
|
||||
import "./Clan.css";
|
||||
import { Modal } from "@/src/components/Modal/Modal";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||
|
||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
const params = useParams();
|
||||
const clanURI = clanURIParam(params);
|
||||
return <CubeScene />;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
<ClanSceneController />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreateFormValues extends FieldValues {
|
||||
name: string;
|
||||
}
|
||||
interface MockProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (formValues: CreateFormValues) => void;
|
||||
}
|
||||
const MockCreateMachine = (props: MockProps) => {
|
||||
let container: Node;
|
||||
|
||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
||||
|
||||
return (
|
||||
<div ref={(el) => (container = el)} class="create-backdrop">
|
||||
<Modal
|
||||
mount={container!}
|
||||
onClose={() => {
|
||||
reset(form);
|
||||
props.onClose();
|
||||
}}
|
||||
class="create-modal"
|
||||
title="Create Machine"
|
||||
>
|
||||
{() => (
|
||||
<Form class="flex flex-col" onSubmit={props.onSubmit}>
|
||||
<Field name="name">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<TextInput
|
||||
{...field}
|
||||
label="Name"
|
||||
size="s"
|
||||
required={true}
|
||||
input={{ ...props, placeholder: "name" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full items-center justify-end gap-4">
|
||||
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="s"
|
||||
type="submit"
|
||||
hierarchy="primary"
|
||||
onClick={close}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClanSceneController = () => {
|
||||
const clanURI = useClanURI({ force: true });
|
||||
|
||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
} | null>(null);
|
||||
|
||||
const onCreate = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowModal(true);
|
||||
setDialogHandlers({ resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const sendCreate = async (values: CreateFormValues) => {
|
||||
const api = callApi("create_machine", {
|
||||
opts: {
|
||||
clan_dir: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
machine: {
|
||||
name: values.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await api.result;
|
||||
if (res.status === "error") {
|
||||
// TODO: Handle displaying errors
|
||||
console.error("Error creating machine:");
|
||||
|
||||
// Important: rejects the promise
|
||||
throw new Error(res.errors[0].message);
|
||||
}
|
||||
return { id: values.name };
|
||||
};
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
<SceneDataProvider clanURI={clanURI}>
|
||||
{({ query }) => {
|
||||
return (
|
||||
<>
|
||||
<Show when={showModal()}>
|
||||
<MockCreateMachine
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
dialogHandlers()?.reject(new Error("User cancelled"));
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const result = await sendCreate(values);
|
||||
dialogHandlers()?.resolve(result);
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
dialogHandlers()?.reject(err);
|
||||
setShowModal(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex flex-row"
|
||||
style={{ position: "absolute", top: "10px", left: "10px" }}
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
onClick={() => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
for (const machineId in s.sceneData[clanURI]) {
|
||||
// Reset the position of each machine to [0, 0]
|
||||
s.sceneData[clanURI] = {}; // Clear the entire object
|
||||
// delete s.sceneData[clanURI][machineId];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Reset Store
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
onClick={() => {
|
||||
console.log("Refetching API");
|
||||
query.refetch();
|
||||
}}
|
||||
>
|
||||
Refetch API
|
||||
</Button>
|
||||
</div>
|
||||
{/* TODO: Add minimal display time */}
|
||||
<div class={cx({ "fade-out": !query.isLoading })}>
|
||||
<Splash />
|
||||
</div>
|
||||
|
||||
<CubeScene
|
||||
isLoading={query.isLoading}
|
||||
cubesQuery={query}
|
||||
onCreate={onCreate}
|
||||
sceneStore={() => {
|
||||
const clanURI = useClanURI({ force: true });
|
||||
return store.sceneData?.[clanURI];
|
||||
}}
|
||||
setMachinePos={(machineId: string, pos: [number, number]) => {
|
||||
console.log("calling setStore", machineId, pos);
|
||||
setStore(
|
||||
produce((s) => {
|
||||
if (!s.sceneData) {
|
||||
s.sceneData = {};
|
||||
}
|
||||
if (!s.sceneData[clanURI]) {
|
||||
s.sceneData[clanURI] = {};
|
||||
}
|
||||
if (!s.sceneData[clanURI][machineId]) {
|
||||
s.sceneData[clanURI][machineId] = { position: pos };
|
||||
} else {
|
||||
s.sceneData[clanURI][machineId].position = pos;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SceneDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const SceneDataProvider = (props: {
|
||||
clanURI: string | null;
|
||||
children: (sceneData: { query: MachinesQueryResult }) => JSX.Element;
|
||||
}) => {
|
||||
const machinesQuery = useMachinesQuery({ clanURI: props.clanURI });
|
||||
|
||||
// This component can be used to provide scene data or context if needed
|
||||
return props.children({ query: machinesQuery });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Component } from "solid-js";
|
||||
import { RouteSectionProps } from "@solidjs/router";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { activeClanURI } from "@/src/stores/clan";
|
||||
import { navigateToClan } from "@/src/hooks/clan";
|
||||
|
||||
export const Layout: Component<RouteSectionProps> = (props) => (
|
||||
<div class="size-full h-screen">{props.children}</div>
|
||||
);
|
||||
export const Layout: Component<RouteSectionProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// check for an active clan uri and redirect to it on first load
|
||||
const activeURI = activeClanURI();
|
||||
if (!props.location.pathname.startsWith("/clans/") && activeURI) {
|
||||
navigateToClan(navigate, activeURI);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return <div class="size-full h-screen">{props.children}</div>;
|
||||
};
|
||||
|
||||
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 {
|
||||
@apply flex flex-col items-center justify-evenly gap-y-20;
|
||||
@apply size-fit;
|
||||
@apply flex flex-col items-center justify-evenly gap-y-20 size-fit;
|
||||
|
||||
& > div.welcome {
|
||||
@apply flex flex-col min-w-80 gap-y-6;
|
||||
@@ -66,7 +65,7 @@ main#welcome {
|
||||
}
|
||||
|
||||
& > div.setup {
|
||||
@apply flex flex-col min-w-[520px] gap-y-5;
|
||||
@apply flex flex-col w-[33rem] gap-y-5;
|
||||
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
|
||||
|
||||
& > div.header {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { Component, createSignal, Match, Setter, Show, Switch } from "solid-js";
|
||||
import {
|
||||
Accessor,
|
||||
Component,
|
||||
createSignal,
|
||||
Match,
|
||||
Setter,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import "./Onboarding.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Alert } from "@/src/components/Alert/Alert";
|
||||
|
||||
import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { Logo } from "@/src/components/Logo/Logo";
|
||||
import { navigateToClan, selectClanFolder } from "@/src/hooks/clan";
|
||||
import { activeClanURI } from "@/src/stores/clan";
|
||||
import { activeClanURI, addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||
import {
|
||||
createForm,
|
||||
FormStore,
|
||||
getError,
|
||||
getErrors,
|
||||
getValue,
|
||||
SubmitHandler,
|
||||
valiForm,
|
||||
} from "@modular-forms/solid";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
@@ -20,23 +31,32 @@ import { TextArea } from "@/src/components/Form/TextArea";
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import * as v from "valibot";
|
||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { Creating } from "./Creating";
|
||||
|
||||
type State = "welcome" | "setup";
|
||||
type State = "welcome" | "setup" | "creating";
|
||||
|
||||
const SetupSchema = v.object({
|
||||
name: v.pipe(v.string(), v.nonEmpty("Please enter a name.")),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.nonEmpty("Please enter a name."),
|
||||
v.regex(
|
||||
new RegExp("^[a-zA-Z0-9_\\-]+$"),
|
||||
"Name must be alphanumeric and can contain underscores and dashes, without spaces.",
|
||||
),
|
||||
),
|
||||
description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
|
||||
directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
|
||||
directory: v.pipe(
|
||||
// initial value is undefined, and I can't see how to handle this better in valibot, so for now when the type
|
||||
// is incorrect we treat it as empty
|
||||
v.string("Please select a directory."),
|
||||
v.nonEmpty("Please select a directory."),
|
||||
),
|
||||
});
|
||||
|
||||
type SetupForm = v.InferInput<typeof SetupSchema>;
|
||||
|
||||
interface backgroundProps {
|
||||
state: State;
|
||||
form: FormStore<SetupForm>;
|
||||
}
|
||||
|
||||
const background = (props: backgroundProps) => (
|
||||
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
|
||||
<div class="background">
|
||||
<div class="layer-1" />
|
||||
<div class="layer-2" />
|
||||
@@ -71,7 +91,11 @@ const background = (props: backgroundProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const welcome = (setState: Setter<State>) => {
|
||||
const welcome = (props: {
|
||||
setState: Setter<State>;
|
||||
welcomeError: Accessor<string | undefined>;
|
||||
setWelcomeError: Setter<string | undefined>;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const selectFolder = async () => {
|
||||
@@ -91,7 +115,23 @@ const welcome = (setState: Setter<State>) => {
|
||||
Build your <br />
|
||||
own darknet
|
||||
</Typography>
|
||||
<Button hierarchy="secondary" onClick={() => setState("setup")}>
|
||||
{props.welcomeError() && (
|
||||
<Alert
|
||||
type="error"
|
||||
icon="Info"
|
||||
title="Your Clan creation failed"
|
||||
description={props.welcomeError() || ""}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
onClick={() => {
|
||||
// reset welcome error
|
||||
props.setWelcomeError(undefined);
|
||||
// move to next step
|
||||
props.setState("setup");
|
||||
}}
|
||||
>
|
||||
Start building
|
||||
</Button>
|
||||
<div class="separator">
|
||||
@@ -126,13 +166,89 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
|
||||
const [state, setState] = createSignal<State>("welcome");
|
||||
|
||||
// used to display an error in the welcome screen in the event of a failed
|
||||
// clan creation
|
||||
const [welcomeError, setWelcomeError] = createSignal<string | undefined>();
|
||||
|
||||
//
|
||||
const [setupForm, { Form, Field }] = createForm<SetupForm>({
|
||||
validate: valiForm(SetupSchema),
|
||||
});
|
||||
|
||||
const metaError = () => {
|
||||
const errors = getErrors(setupForm, ["name", "description"]);
|
||||
return errors ? errors.name || errors.description : undefined;
|
||||
const formError = () => {
|
||||
const formErrors = getErrors(setupForm);
|
||||
return (
|
||||
formErrors.name ||
|
||||
formErrors.description ||
|
||||
formErrors.directory ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
const onSelectFile = async () => {
|
||||
const req = callApi("get_system_file", {
|
||||
file_request: {
|
||||
mode: "select_folder",
|
||||
title: "Select a folder for you new Clan",
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await req.result;
|
||||
|
||||
if (resp.status === "error") {
|
||||
// just throw the first error, I can't imagine why there would be multiple
|
||||
// errors for this call
|
||||
throw new Error(resp.errors[0].message);
|
||||
}
|
||||
|
||||
if (resp.status === "success" && resp.data) {
|
||||
return resp.data[0];
|
||||
}
|
||||
|
||||
throw new Error("No data returned from api call");
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<SetupForm> = async (
|
||||
{ name, description, directory },
|
||||
event,
|
||||
) => {
|
||||
const path = `${directory}/${name}`;
|
||||
|
||||
const req = callApi("create_clan", {
|
||||
opts: {
|
||||
dest: path,
|
||||
// todo allow users to select a template
|
||||
template: "minimal",
|
||||
initial: {
|
||||
meta: {
|
||||
name: name,
|
||||
description: description,
|
||||
// todo it tries to 'delete' icon if it's not provided
|
||||
// this logic is unexpected, and needs reviewed.
|
||||
icon: null,
|
||||
},
|
||||
machines: {},
|
||||
instances: {},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setState("creating");
|
||||
|
||||
const resp = await req.result;
|
||||
|
||||
if (resp.status === "error") {
|
||||
setWelcomeError(resp.errors[0].message);
|
||||
setState("welcome");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.status === "success") {
|
||||
addClanURI(path);
|
||||
setActiveClanURI(path);
|
||||
navigateToClan(navigate, path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -140,7 +256,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
{background({ form: setupForm, state: state() })}
|
||||
<div class="container">
|
||||
<Switch>
|
||||
<Match when={state() === "welcome"}>{welcome(setState)}</Match>
|
||||
<Match when={state() === "welcome"}>
|
||||
{welcome({
|
||||
setState,
|
||||
welcomeError,
|
||||
setWelcomeError,
|
||||
})}
|
||||
</Match>
|
||||
|
||||
<Match when={state() === "setup"}>
|
||||
<div class="setup">
|
||||
@@ -155,8 +277,16 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
Setup
|
||||
</Typography>
|
||||
</div>
|
||||
<Form>
|
||||
<Fieldset name="meta" error={metaError()}>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{formError() && (
|
||||
<Alert
|
||||
type="error"
|
||||
icon="Info"
|
||||
title="Form error"
|
||||
description={formError() || ""}
|
||||
/>
|
||||
)}
|
||||
<Fieldset name="meta">
|
||||
<Field name="name">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
@@ -195,15 +325,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
</Field>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset
|
||||
name="location"
|
||||
error={getError(setupForm, "directory")}
|
||||
>
|
||||
<Fieldset name="location">
|
||||
<Field name="directory">
|
||||
{(field, input) => (
|
||||
<HostFileInput
|
||||
onSelectFile={async () => "test"}
|
||||
onSelectFile={onSelectFile}
|
||||
{...field}
|
||||
value={field.value}
|
||||
label="Select directory"
|
||||
orientation="horizontal"
|
||||
required={true}
|
||||
@@ -228,6 +356,10 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
</Form>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={state() === "creating"}>
|
||||
<Creating />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -8,7 +8,41 @@ export const Routes: RouteDefinition[] = [
|
||||
component: Onboarding,
|
||||
},
|
||||
{
|
||||
path: "/clan/:clanURI",
|
||||
component: Clan,
|
||||
path: "/clans",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => (
|
||||
<h1>
|
||||
Clans (index) - (Doesnt really exist, just to keep the scene
|
||||
mounted)
|
||||
</h1>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/:clanURI",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: Clan,
|
||||
},
|
||||
{
|
||||
path: "/machines",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => <h1>Machines (Index)</h1>,
|
||||
},
|
||||
{
|
||||
path: "/:machineID",
|
||||
component: (props) => (
|
||||
<h1>Machine ID: {props.params.machineID}</h1>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 { makePersisted } from "@solid-primitives/storage";
|
||||
|
||||
interface ClanStoreType {
|
||||
export type SceneData = Record<string, { position: [number, number] }>;
|
||||
|
||||
export interface ClanStoreType {
|
||||
clanURIs: string[];
|
||||
activeClanURI?: string;
|
||||
sceneData: Record<string, SceneData>;
|
||||
}
|
||||
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<ClanStoreType>({
|
||||
clanURIs: [],
|
||||
sceneData: {},
|
||||
}),
|
||||
{
|
||||
name: "clanStore",
|
||||
@@ -22,7 +26,7 @@ const [store, setStore] = makePersisted(
|
||||
* @function
|
||||
* @returns {string} The URI of the active clan.
|
||||
*/
|
||||
const activeClanURI = (): string | undefined => store.activeClanURI;
|
||||
const activeClanURI = () => store.activeClanURI;
|
||||
|
||||
/**
|
||||
* Updates the active Clan URI in the store.
|
||||
@@ -45,8 +49,10 @@ const clanURIs = (): string[] => store.clanURIs;
|
||||
* @param {string} uri - The URI of the clan to be added.
|
||||
*
|
||||
*/
|
||||
const addClanURI = (uri: string) =>
|
||||
const addClanURI = (uri: string) => {
|
||||
setStore("clanURIs", store.clanURIs.length, uri);
|
||||
setStore("sceneData", uri, {}); // Initialize empty scene data for every new clan URI
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a specified URI from the clan URI list and updates the active clan URI.
|
||||
@@ -80,6 +86,7 @@ const removeClanURI = (uri: string) => {
|
||||
|
||||
export {
|
||||
store,
|
||||
setStore,
|
||||
activeClanURI,
|
||||
setActiveClanURI,
|
||||
clanURIs,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
gtk4,
|
||||
webkitgtk_6_0,
|
||||
lib,
|
||||
clangStdenv,
|
||||
fetchFromGitea,
|
||||
gnumake,
|
||||
cmake,
|
||||
clang-tools,
|
||||
pkg-config,
|
||||
stdenv,
|
||||
...
|
||||
}:
|
||||
|
||||
pkgs.clangStdenv.mkDerivation {
|
||||
clangStdenv.mkDerivation {
|
||||
pname = "webview";
|
||||
version = "nightly";
|
||||
|
||||
@@ -8,7 +20,7 @@ pkgs.clangStdenv.mkDerivation {
|
||||
# We disallow remote connections from the UI on Linux
|
||||
# TODO: Disallow remote connections on MacOS
|
||||
|
||||
src = pkgs.fetchFromGitea {
|
||||
src = fetchFromGitea {
|
||||
domain = "git.clan.lol";
|
||||
owner = "clan";
|
||||
repo = "webview";
|
||||
@@ -37,23 +49,19 @@ pkgs.clangStdenv.mkDerivation {
|
||||
];
|
||||
|
||||
# Dependencies used during the build process, if any
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nativeBuildInputs = [
|
||||
gnumake
|
||||
cmake
|
||||
clang-tools
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
[
|
||||
]
|
||||
++ pkgs.lib.optionals stdenv.isLinux [
|
||||
webkitgtk_6_0
|
||||
gtk4
|
||||
];
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
webkitgtk_6_0
|
||||
gtk4
|
||||
];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
meta = with lib; {
|
||||
description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)";
|
||||
homepage = "https://github.com/webview/webview";
|
||||
license = licenses.mit;
|
||||
|
||||
@@ -25,6 +25,7 @@ from .facts import cli as facts
|
||||
from .flash import cli as flash_cli
|
||||
from .hyperlink import help_hyperlink
|
||||
from .machines import cli as machines
|
||||
from .network import cli as network_cli
|
||||
from .profiler import profile
|
||||
from .ssh import deploy_info as ssh_cli
|
||||
from .vars import cli as vars_cli
|
||||
@@ -428,6 +429,26 @@ Examples:
|
||||
)
|
||||
select.register_parser(parser_select)
|
||||
|
||||
parser_network = subparsers.add_parser(
|
||||
"network",
|
||||
aliases=["net"],
|
||||
# TODO: Add help="Manage networks" when network code is ready
|
||||
# help="Manage networks",
|
||||
description="Manage networks",
|
||||
epilog=(
|
||||
"""
|
||||
show information about configured networks
|
||||
|
||||
Examples:
|
||||
|
||||
$ clan network list
|
||||
Will list networks
|
||||
"""
|
||||
),
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
network_cli.register_parser(parser_network)
|
||||
|
||||
parser_state = subparsers.add_parser(
|
||||
"state",
|
||||
aliases=["st"],
|
||||
@@ -462,7 +483,7 @@ For more detailed information, visit: {help_hyperlink("getting-started", "https:
|
||||
state.register_parser(parser_state)
|
||||
|
||||
if argcomplete:
|
||||
argcomplete.autocomplete(parser, exclude=["morph"])
|
||||
argcomplete.autocomplete(parser, exclude=["morph", "network", "net"])
|
||||
|
||||
register_common_flags(parser)
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from clan_cli.cli import create_parser
|
||||
|
||||
hidden_subcommands = ["machine", "b", "f", "m", "se", "st", "va"]
|
||||
hidden_subcommands = ["machine", "b", "f", "m", "se", "st", "va", "net", "network"]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
||||
|
||||
outputs =
|
||||
{ self, clan-core, ... }:
|
||||
{
|
||||
self,
|
||||
clan-core,
|
||||
nixpkgs,
|
||||
...
|
||||
}@inputs:
|
||||
let
|
||||
# Usage see: https://docs.clan.lol
|
||||
clan = clan-core.lib.clan {
|
||||
inherit self;
|
||||
imports = [ ./clan.nix ];
|
||||
specialArgs = { inherit inputs; };
|
||||
};
|
||||
in
|
||||
{
|
||||
@@ -16,7 +22,7 @@
|
||||
# Add the Clan cli tool to the dev shell.
|
||||
# Use "nix develop" to enter the dev shell.
|
||||
devShells =
|
||||
clan-core.inputs.nixpkgs.lib.genAttrs
|
||||
nixpkgs.lib.genAttrs
|
||||
[
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
|
||||
Reference in New Issue
Block a user