Compare commits

..

3 Commits

Author SHA1 Message Date
Brian McGee
9225e707b8 docs: wip styling of options 2025-07-16 12:22:48 +02:00
Jörg Thalheim
d45c5ac637 waypipe: disable gpu for now 2025-07-16 12:22:48 +02:00
Jörg Thalheim
20ad968d04 waypipe: disable gpu for now 2025-07-16 11:55:15 +02:00
75 changed files with 892 additions and 3635 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ in
};
perInstance =
{ instanceName, settings, ... }:
{ 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-${instanceName}" = {
systemd.services.NetworkManager-setup-secrets = {
description = "Generate wifi secrets for NetworkManager";
requiredBy = [ "NetworkManager-ensure-profiles.service" ];
partOf = [ "NetworkManager-ensure-profiles.service" ];

View File

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

View File

@@ -11,7 +11,7 @@
...
}:
let
clanOptions = self'.legacyPackages.clan-internals-docs;
buildClanOptions = 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=${clanOptions}/share/doc/nixos/options.json
export BUILD_CLAN_PATH=${buildClanOptions}/share/doc/nixos/options.json
mkdir $out

View File

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

View File

View File

@@ -1,14 +1,6 @@
{% extends "base.html" %} {% block extrahead %}
<style>
.md-main__inner {
max-width: 100% !important;
}
.md-content {
max-width: 100% !important;
}
.md-main__inner {
margin-top: 0 !important;
}
</style>
{% endblock %} {% block site_nav %}{% endblock %} {% block content %} {{
page.content }} {% endblock %}

View File

@@ -35,37 +35,6 @@ 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`
@@ -101,56 +70,6 @@ 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
@@ -212,33 +131,6 @@ 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
@@ -246,89 +138,8 @@ roles.moon.machines.eve = { };
* `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)

View File

@@ -2,5 +2,86 @@
template: options.html
---
<script>
<iframe src="/options-page/" height="1000" width="100%"></iframe>
const variables = [
'--md-default-bg-color',
'--md-default-fg-color',
'--md-default-fg-color--light',
'--md-default-fg-color--lightest'
];
let colorScheme = 'default';
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-md-color-scheme') {
colorScheme = mutation.target.getAttribute('data-md-color-scheme');
console.log('color scheme changed', colorScheme);
}
});
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-md-color-scheme']
});
function syncCSSVariables() {
const iframe = document.getElementById('options-frame');
console.log('syncing css variables', iframe);
const iframeDoc = iframe.contentWindow.document;
const iframeRoot = iframeDoc.documentElement;
const targetElement = document.querySelector(`[data-md-color-scheme="${colorScheme}"]`);
const parentStyles = getComputedStyle(targetElement);
console.log('parent styles', parentStyles);
variables.forEach(varName => {
const value = parentStyles.getPropertyValue(varName);
if (value.trim()) {
console.log('setting', varName, value);
iframeRoot.style.setProperty(varName, value.trim());
}
});
// add our custom styling
addCustomCSS(iframe);
}
function addCustomCSS(iframe) {
const iframeDoc = iframe.contentWindow.document;
const cssLink = iframeDoc.createElement('link');
cssLink.id = "clan-css";
cssLink.rel = "stylesheet";
cssLink.type = "text/css";
cssLink.href = "/static/options.css";
iframeDoc.head.appendChild(cssLink);
}
function onIFrameLoad() {
const iframe = document.getElementById('options-frame');
// initial sync of css variables
syncCSSVariables(iframe);
// listen for theme changes
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
const lightModeQuery = window.matchMedia('(prefers-color-scheme: light)');
darkModeQuery.addEventListener('change', syncCSSVariables);
lightModeQuery.addEventListener('change', syncCSSVariables);
}
</script>
<iframe id="options-frame" src="/options-page/" onload="onIFrameLoad()" height="1000" width="100%"></iframe>
[asciinema-player](static/asciinema-player)

View File

@@ -20,3 +20,7 @@
.md-nav__item.md-nav__item--section > label > span {
color: var(--md-typeset-a-color);
}
iframe {
border: none;
}

View File

@@ -0,0 +1,42 @@
@font-face {
font-family: "Roboto";
src: url(./Roboto-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Fira Code";
src: url(./FiraCode-VF.ttf) format("truetype");
}
:root {
--f-family: "Roboto";
--f-family-mono: "Fira Code";
--c-page: var(--md-default-bg-color);
--c-card: transparent;
}
header h1 {
color: var(--md-default-fg-color--light);
}
div.card {
border: .05rem solid var(--md-default-fg-color--lightest) !important;
}
form {
label {
gap: 1rem;
input {
background-color: #00000042 !important;
&:hover {
background-color: #ffffff1f !important;
}
}
}
}

20
flake.lock generated
View File

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

View File

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

View File

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

View File

@@ -78,87 +78,7 @@ in
internal = true;
visible = false;
type = types.deferredModule;
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";
};
};
};
};
};
};
};
}
)
);
};
};
}
);
};
};
default = { };
description = ''
A module that is used to define the module of flake level exports -

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,6 @@ 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 & {
@@ -22,21 +20,10 @@ export type HostFileInputProps = FieldProps &
};
export const HostFileInput = (props: HostFileInputProps) => {
const [value, setValue] = createSignal<string>(props.value || "");
let actualInputElement: HTMLInputElement | undefined;
const [value, setValue] = createSignal<string | undefined>(undefined);
const selectFile = async () => {
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
}
setValue(await props.onSelectFile());
};
return (
@@ -46,65 +33,26 @@ export const HostFileInput = (props: HostFileInputProps) => {
ghost: props.ghost,
})}
{...props}
value={value()}
onChange={setValue}
>
<Orienter
orientation={props.orientation}
align={props.orientation == "horizontal" ? "center" : "start"}
>
<Orienter orientation={props.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.Input
{...props.input}
hidden={true}
value={value()}
ref={(el: HTMLInputElement) => {
actualInputElement = el; // Capture for local use
}}
/>
<TextField.Input {...props.input} hidden={true} />
{!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>
)}
<Button
hierarchy="secondary"
size={props.size}
startIcon="Folder"
onClick={selectFile}
>
{value() ? value() : "No Selection"}
</Button>
</Orienter>
</TextField>
);

View File

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

View File

@@ -1,11 +1,12 @@
import { Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { Tooltip } from "@/src/components/Tooltip/Tooltip";
import { Tooltip as KTooltip } from "@kobalte/core/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";
@@ -48,27 +49,31 @@ export const Label = (props: LabelProps) => {
{props.label}
</Typography>
{props.tooltip && (
<Tooltip
placement="top"
inverted={props.inverted}
trigger={
<KTooltip placement="top">
<KTooltip.Trigger>
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
}
>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{props.tooltip}
</Typography>
</Tooltip>
<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>
)}
</props.labelComponent>
{props.description && (

View File

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

View File

@@ -1,5 +1,5 @@
div.modal-content {
@apply min-w-[320px] max-w-[512px];
@apply 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;
& > .modal-title {
& > .title {
@apply mx-auto;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
.fade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
.create-backdrop {
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
}
.create-modal {
@apply min-w-96;
}

View File

@@ -1,231 +1,10 @@
import { RouteSectionProps } from "@solidjs/router";
import { Component, JSX, Show, createSignal } from "solid-js";
import { useClanURI } from "@/src/hooks/clan";
import { RouteSectionProps, useParams } from "@solidjs/router";
import { Component } from "solid-js";
import { clanURIParam } 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) => {
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 });
const params = useParams();
const clanURI = clanURIParam(params);
return <CubeScene />;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,18 @@
import {
Accessor,
Component,
createSignal,
Match,
Setter,
Show,
Switch,
} from "solid-js";
import { 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, addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { activeClanURI } from "@/src/stores/clan";
import {
createForm,
FormStore,
getError,
getErrors,
getValue,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { TextInput } from "@/src/components/Form/TextInput";
@@ -31,32 +20,23 @@ 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" | "creating";
type State = "welcome" | "setup";
const SetupSchema = v.object({
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.",
),
),
name: v.pipe(v.string(), v.nonEmpty("Please enter a name.")),
description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
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."),
),
directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
});
type SetupForm = v.InferInput<typeof SetupSchema>;
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
interface backgroundProps {
state: State;
form: FormStore<SetupForm>;
}
const background = (props: backgroundProps) => (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
@@ -91,11 +71,7 @@ const background = (props: { state: State; form: FormStore<SetupForm> }) => (
</div>
);
const welcome = (props: {
setState: Setter<State>;
welcomeError: Accessor<string | undefined>;
setWelcomeError: Setter<string | undefined>;
}) => {
const welcome = (setState: Setter<State>) => {
const navigate = useNavigate();
const selectFolder = async () => {
@@ -115,23 +91,7 @@ const welcome = (props: {
Build your <br />
own darknet
</Typography>
{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");
}}
>
<Button hierarchy="secondary" onClick={() => setState("setup")}>
Start building
</Button>
<div class="separator">
@@ -166,89 +126,13 @@ 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 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);
}
const metaError = () => {
const errors = getErrors(setupForm, ["name", "description"]);
return errors ? errors.name || errors.description : undefined;
};
return (
@@ -256,13 +140,7 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
{background({ form: setupForm, state: state() })}
<div class="container">
<Switch>
<Match when={state() === "welcome"}>
{welcome({
setState,
welcomeError,
setWelcomeError,
})}
</Match>
<Match when={state() === "welcome"}>{welcome(setState)}</Match>
<Match when={state() === "setup"}>
<div class="setup">
@@ -277,16 +155,8 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
Setup
</Typography>
</div>
<Form onSubmit={onSubmit}>
{formError() && (
<Alert
type="error"
icon="Info"
title="Form error"
description={formError() || ""}
/>
)}
<Fieldset name="meta">
<Form>
<Fieldset name="meta" error={metaError()}>
<Field name="name">
{(field, input) => (
<TextInput
@@ -325,13 +195,15 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Field>
</Fieldset>
<Fieldset name="location">
<Fieldset
name="location"
error={getError(setupForm, "directory")}
>
<Field name="directory">
{(field, input) => (
<HostFileInput
onSelectFile={onSelectFile}
onSelectFile={async () => "test"}
{...field}
value={field.value}
label="Select directory"
orientation="horizontal"
required={true}
@@ -356,10 +228,6 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Form>
</div>
</Match>
<Match when={state() === "creating"}>
<Creating />
</Match>
</Switch>
</div>
</main>

View File

@@ -8,41 +8,7 @@ export const Routes: RouteDefinition[] = [
component: Onboarding,
},
{
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>
),
},
],
},
],
},
],
path: "/clan/:clanURI",
component: Clan,
},
];

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,14 @@
import { createStore, produce } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage";
export type SceneData = Record<string, { position: [number, number] }>;
export interface ClanStoreType {
interface ClanStoreType {
clanURIs: string[];
activeClanURI?: string;
sceneData: Record<string, SceneData>;
}
const [store, setStore] = makePersisted(
createStore<ClanStoreType>({
clanURIs: [],
sceneData: {},
}),
{
name: "clanStore",
@@ -26,7 +22,7 @@ const [store, setStore] = makePersisted(
* @function
* @returns {string} The URI of the active clan.
*/
const activeClanURI = () => store.activeClanURI;
const activeClanURI = (): string | undefined => store.activeClanURI;
/**
* Updates the active Clan URI in the store.
@@ -49,10 +45,8 @@ 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.
@@ -86,7 +80,6 @@ const removeClanURI = (uri: string) => {
export {
store,
setStore,
activeClanURI,
setActiveClanURI,
clanURIs,

View File

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

View File

@@ -25,7 +25,6 @@ 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
@@ -429,26 +428,6 @@ 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"],
@@ -483,7 +462,7 @@ For more detailed information, visit: {help_hyperlink("getting-started", "https:
state.register_parser(parser_state)
if argcomplete:
argcomplete.autocomplete(parser, exclude=["morph", "network", "net"])
argcomplete.autocomplete(parser, exclude=["morph"])
register_common_flags(parser)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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