Compare commits

..

1 Commits

Author SHA1 Message Date
Brian McGee
30edcacce5 feat(ui): reduce opacity of sidebar pane content when not editing 2025-07-24 10:01:08 +01:00
151 changed files with 2556 additions and 5012 deletions

View File

@@ -24,7 +24,7 @@ If you're new to Clan and eager to dive in, start with our quickstart guide and
In the Clan ecosystem, security is paramount. Learn how to handle secrets effectively:
- **Secrets Management**: Securely manage secrets by consulting [Vars](https://docs.clan.lol/concepts/generators/)<!-- [secrets.md](docs/site/concepts/generators.md) -->.
- **Secrets Management**: Securely manage secrets by consulting [Vars](https://docs.clan.lol/guides/vars-backend/)<!-- [secrets.md](docs/site/guides/vars-backend.md) -->.
### Contributing to Clan

View File

@@ -1,6 +1,6 @@
{ fetchgit }:
fetchgit {
url = "https://git.clan.lol/clan/clan-core.git";
rev = "ba8a80eccf091fc7f99aef3895e31617d3813d20";
sha256 = "189srg4mc5y3prapm8day0x0wpibbqc72hrnl61agsmiq7cfmbkd";
rev = "eea93ea22c9818da67e148ba586277bab9e73cea";
sha256 = "sha256-PV0Z+97QuxQbkYSVuNIJwUNXMbHZG/vhsA9M4cDTCOE=";
}

View File

@@ -19,30 +19,18 @@ let
nixosLib = import (self.inputs.nixpkgs + "/nixos/lib") { };
in
{
imports =
let
clanCoreModulesDir = ../nixosModules/clanCore;
getClanCoreTestModules =
let
moduleNames = attrNames (builtins.readDir clanCoreModulesDir);
testPaths = map (
moduleName: clanCoreModulesDir + "/${moduleName}/tests/flake-module.nix"
) moduleNames;
in
filter pathExists testPaths;
in
getClanCoreTestModules
++ filter pathExists [
./backups/flake-module.nix
./devshell/flake-module.nix
./flash/flake-module.nix
./impure/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
];
imports = filter pathExists [
./backups/flake-module.nix
../nixosModules/clanCore/machine-id/tests/flake-module.nix
../nixosModules/clanCore/state-version/tests/flake-module.nix
./devshell/flake-module.nix
./flash/flake-module.nix
./impure/flake-module.nix
./installation/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
];
flake.check = genAttrs [ "x86_64-linux" "aarch64-darwin" ] (
system:
let
@@ -100,6 +88,7 @@ in
nixos-test-container = self.clanLib.test.containerTest ./container nixosTestArgs;
nixos-test-zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs;
nixos-test-matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs;
nixos-test-postgresql = self.clanLib.test.containerTest ./postgresql nixosTestArgs;
nixos-test-user-firewall-iptables = self.clanLib.test.containerTest ./user-firewall/iptables.nix nixosTestArgs;
nixos-test-user-firewall-nftables = self.clanLib.test.containerTest ./user-firewall/nftables.nix nixosTestArgs;
@@ -158,11 +147,8 @@ in
clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } ''
cp -r ${pkgs.callPackage ./clan-core-for-checks.nix { }} $out
chmod -R +w $out
chmod +w $out/flake.lock
cp ${../flake.lock} $out/flake.lock
# Create marker file to disable private flake loading in tests
touch $out/.skip-private-inputs
'';
};
packages = lib.optionalAttrs (pkgs.stdenv.isLinux) {

View File

@@ -149,6 +149,7 @@
# vm-test-run-test-installation-> target: To debug, enter the VM and run 'systemctl status backdoor.service'.
checks =
let
# Custom Python package for port management utilities
closureInfo = pkgs.closureInfo {
rootPaths = [
self.checks.x86_64-linux.clan-core-for-checks
@@ -224,7 +225,7 @@
"install",
"--phases", "disko,install",
"--debug",
"--flake", str(flake_dir),
"--flake", flake_dir,
"--yes", "test-install-machine-without-system",
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
"-i", ssh_conn.ssh_key,
@@ -288,6 +289,9 @@
assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially"
assert not os.path.exists(facter_file), "facter.json should not exist initially"
# Set CLAN_FLAKE for the commands
os.environ["CLAN_FLAKE"] = flake_dir
# Test facter backend
clan_cmd = [
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",

View File

@@ -0,0 +1,73 @@
({
name = "postgresql";
nodes.machine =
{ self, config, ... }:
{
imports = [
self.nixosModules.clanCore
self.clanModules.postgresql
self.clanModules.localbackup
];
clan.postgresql.users.test = { };
clan.postgresql.databases.test.create.options.OWNER = "test";
clan.postgresql.databases.test.restore.stopOnRestore = [ "sample-service" ];
clan.localbackup.targets.hdd.directory = "/mnt/external-disk";
clan.core.settings.directory = ./.;
systemd.services.sample-service = {
wantedBy = [ "multi-user.target" ];
script = ''
while true; do
echo "Hello, world!"
sleep 5
done
'';
};
environment.systemPackages = [ config.services.postgresql.package ];
};
testScript =
{ nodes, ... }:
''
start_all()
machine.wait_for_unit("postgresql")
machine.wait_for_unit("sample-service")
# Create a test table
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -c 'CREATE TABLE test (id serial PRIMARY KEY);' test")
machine.succeed("/run/current-system/sw/bin/localbackup-create >&2")
timestamp_before = int(machine.succeed("systemctl show --property=ExecMainStartTimestampMonotonic sample-service | cut -d= -f2").strip())
machine.succeed("test -e /mnt/external-disk/snapshot.0/machine/var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'INSERT INTO test DEFAULT VALUES;'")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'DROP TABLE test;'")
machine.succeed("test -e /var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("rm -rf /var/backup/postgres")
machine.succeed("NAME=/mnt/external-disk/snapshot.0 FOLDERS=/var/backup/postgres/test /run/current-system/sw/bin/localbackup-restore >&2")
machine.succeed("test -e /var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("""
set -x
${nodes.machine.clan.core.state.test.postRestoreCommand}
""")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -l >&2")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c '\dt' >&2")
timestamp_after = int(machine.succeed("systemctl show --property=ExecMainStartTimestampMonotonic sample-service | cut -d= -f2").strip())
assert timestamp_before < timestamp_after, f"{timestamp_before} >= {timestamp_after}: expected sample-service to be restarted after restore"
# Check that the table is still there
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'SELECT * FROM test;'")
output = machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql --csv -c \"SELECT datdba::regrole FROM pg_database WHERE datname = 'test'\"")
owner = output.split("\n")[1]
assert owner == "test", f"Expected database owner to be 'test', got '{owner}'"
# check if restore works if the database does not exist
machine.succeed("runuser -u postgres -- dropdb test")
machine.succeed("${nodes.machine.clan.core.state.test.postRestoreCommand}")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c '\dt' >&2")
'';
})

View File

@@ -29,11 +29,19 @@ nixosLib.runTest (
testScript =
{ nodes, ... }:
''
import subprocess
from nixos_test_lib.nix_setup import setup_nix_in_nix # type: ignore[import-untyped]
setup_nix_in_nix(None) # No closure info for this test
def run_clan(cmd: list[str], **kwargs) -> str:
import subprocess
clan = "${clan-core.packages.${hostPkgs.system}.clan-cli}/bin/clan"
clan_args = ["--flake", "${config.clan.test.flakeForSandbox}"]
return subprocess.run(
["${hostPkgs.util-linux}/bin/unshare", "--user", "--map-user", "1000", "--map-group", "1000", clan, *cmd, *clan_args],
**kwargs,
check=True,
).stdout
start_all()
admin1.wait_for_unit("multi-user.target")
peer1.wait_for_unit("multi-user.target")
@@ -52,13 +60,7 @@ nixosLib.runTest (
# Check that the file is in the '0644' mode
assert "-rw-r--r--" in ls_out, f"File is not in the '0644' mode: {ls_out}"
# Run clan command
result = subprocess.run(
["${
clan-core.packages.${hostPkgs.system}.clan-cli
}/bin/clan", "machines", "list", "--flake", "${config.clan.test.flakeForSandbox}"],
check=True
)
run_clan(["machines", "list"])
'';
}
)

View File

@@ -1,237 +0,0 @@
{ self, ... }:
{
# Machine for update test
clan.machines.test-update-machine = {
imports = [
self.nixosModules.test-update-machine
# Import the configuration file that will be created/updated during the test
./test-update-machine/configuration.nix
];
};
flake.nixosModules.test-update-machine =
{ lib, modulesPath, ... }:
{
imports = [
(modulesPath + "/testing/test-instrumentation.nix")
(modulesPath + "/profiles/qemu-guest.nix")
self.clanLib.test.minifyModule
../../lib/test/container-test-driver/nixos-module.nix
];
# Apply patch to fix x-initrd.mount filesystem handling in switch-to-configuration-ng
nixpkgs.overlays = [
(_final: prev: {
switch-to-configuration-ng = prev.switch-to-configuration-ng.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./switch-to-configuration-initrd-mount-fix.patch ];
});
})
];
networking.hostName = "update-machine";
environment.etc."install-successful".text = "ok";
# Enable SSH and add authorized key for testing
services.openssh.enable = true;
services.openssh.settings.PasswordAuthentication = false;
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
security.sudo.wheelNeedsPassword = false;
boot.consoleLogLevel = lib.mkForce 100;
boot.kernelParams = [ "boot.shell_on_fail" ];
boot.isContainer = true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
# Preserve the IP addresses assigned by the test framework
# (based on virtualisation.vlans = [1] and node number 1)
networking.interfaces.eth1 = {
useDHCP = false;
ipv4.addresses = [
{
address = "192.168.1.1";
prefixLength = 24;
}
];
ipv6.addresses = [
{
address = "2001:db8:1::1";
prefixLength = 64;
}
];
};
# Define the mounts that exist in the container to prevent them from being stopped
fileSystems = {
"/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
options = [ "x-initrd.mount" ];
};
"/nix/.rw-store" = {
device = "tmpfs";
fsType = "tmpfs";
options = [
"mode=0755"
];
};
"/nix/store" = {
device = "overlay";
fsType = "overlay";
options = [
"lowerdir=/nix/.ro-store"
"upperdir=/nix/.rw-store/upper"
"workdir=/nix/.rw-store/work"
];
};
};
};
perSystem =
{
pkgs,
...
}:
{
checks =
pkgs.lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.system == "x86_64-linux")
{
nixos-test-update =
let
closureInfo = pkgs.closureInfo {
rootPaths = [
self.checks.x86_64-linux.clan-core-for-checks
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-update-machine.config.system.build.toplevel
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
};
in
self.clanLib.test.containerTest {
name = "update";
nodes.machine = {
imports = [ self.nixosModules.test-update-machine ];
};
extraPythonPackages = _p: [
self.legacyPackages.${pkgs.system}.nixosTestLib
];
testScript = ''
import tempfile
import os
import subprocess
from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped]
from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped]
start_all()
machine.wait_for_unit("multi-user.target")
# Verify initial state
machine.succeed("test -f /etc/install-successful")
machine.fail("test -f /etc/update-successful")
# Set up test environment
with tempfile.TemporaryDirectory() as temp_dir:
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${closureInfo}"
)
(flake_dir / ".clan-flake").write_text("") # Ensure .clan-flake exists
# Set up SSH connection
ssh_conn = setup_ssh_connection(
machine,
temp_dir,
"${../assets/ssh/privkey}"
)
# Update the machine configuration to add a new file
machine_config_path = os.path.join(flake_dir, "machines", "test-update-machine", "configuration.nix")
os.makedirs(os.path.dirname(machine_config_path), exist_ok=True)
with open(machine_config_path, "w") as f:
f.write("""
{
environment.etc."update-successful".text = "ok";
}
""")
# Run clan update command
# Note: update command doesn't accept -i flag, SSH key must be in ssh-agent
# Start ssh-agent and add the key
agent_output = subprocess.check_output(["${pkgs.openssh}/bin/ssh-agent", "-s"], text=True)
for line in agent_output.splitlines():
if line.startswith("SSH_AUTH_SOCK="):
os.environ["SSH_AUTH_SOCK"] = line.split("=", 1)[1].split(";")[0]
elif line.startswith("SSH_AGENT_PID="):
os.environ["SSH_AGENT_PID"] = line.split("=", 1)[1].split(";")[0]
# Add the SSH key to the agent
subprocess.run(["${pkgs.openssh}/bin/ssh-add", ssh_conn.ssh_key], check=True)
# Run clan update command
subprocess.run([
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"update",
"--debug",
"--flake", flake_dir,
"--host-key-check", "none",
"--fetch-local", # Use local store instead of fetching from network
"test-update-machine",
"--target-host", f"root@192.168.1.1:{ssh_conn.host_port}",
], check=True)
# Verify the update was successful
machine.succeed("test -f /etc/update-successful")
# Test update with --build-host
# Update configuration again to test build-host functionality
with open(machine_config_path, "w") as f:
f.write("""
{
environment.etc."build-host-update-successful".text = "ok";
}
""")
# Run clan update command with --build-host
subprocess.run([
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"update",
"--debug",
"--flake", flake_dir,
"--host-key-check", "none",
"--fetch-local", # Use local store instead of fetching from network
"--build-host", f"root@192.168.1.1:{ssh_conn.host_port}",
"test-update-machine",
"--target-host", f"root@192.168.1.1:{ssh_conn.host_port}",
], check=True)
# Verify the second update was successful
machine.succeed("test -f /etc/build-host-update-successful")
# Run clan update command with --build-host
subprocess.run([
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"update",
"--debug",
"--flake", flake_dir,
"--host-key-check", "none",
"--fetch-local", # Use local store instead of fetching from network
"--build-host", f"root@192.168.1.1:{ssh_conn.host_port}",
"test-update-machine",
"--target-host", f"root@192.168.1.1:{ssh_conn.host_port}",
], check=True)
# Verify the second update was successful
machine.succeed("test -f /etc/build-host-update-successful")
'';
} { inherit pkgs self; };
};
};
}

View File

@@ -1,17 +0,0 @@
diff --git a/src/main.rs b/src/main.rs
index 8baf5924a7db..1234567890ab 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1295,6 +1295,12 @@ won't take effect until you reboot the system.
for (mountpoint, current_filesystem) in current_filesystems {
// Use current version of systemctl binary before daemon is reexeced.
+
+ // Skip filesystem comparison if x-initrd.mount is present in options
+ if current_filesystem.options.contains("x-initrd.mount") {
+ continue;
+ }
+
let unit = path_to_unit_name(&current_system_bin, &mountpoint);
if let Some(new_filesystem) = new_filesystems.get(&mountpoint) {
if current_filesystem.fs_type != new_filesystem.fs_type

View File

@@ -1,3 +0,0 @@
{
# Initial empty configuration
}

View File

@@ -4,7 +4,7 @@ description = "Statically configure borgbackup with sane defaults."
!!! Danger "Deprecated"
Use [borgbackup](borgbackup.md) instead.
Don't use borgbackup-static through [inventory](../../concepts/inventory.md).
Don't use borgbackup-static through [inventory](../../guides/inventory.md).
This module implements the `borgbackup` backend and implements sane defaults
for backup management through `borgbackup` for members of the clan.

View File

@@ -61,6 +61,7 @@ in
};
};
imports = [
../postgresql
(lib.mkRemovedOptionModule [
"clan"
"matrix-synapse"
@@ -105,16 +106,15 @@ in
};
};
clan.core.postgresql.enable = true;
clan.core.postgresql.users.matrix-synapse = { };
clan.core.postgresql.databases.matrix-synapse.create.options = {
clan.postgresql.users.matrix-synapse = { };
clan.postgresql.databases.matrix-synapse.create.options = {
TEMPLATE = "template0";
LC_COLLATE = "C";
LC_CTYPE = "C";
ENCODING = "UTF8";
OWNER = "matrix-synapse";
};
clan.core.postgresql.databases.matrix-synapse.restore.stopOnRestore = [ "matrix-synapse" ];
clan.postgresql.databases.matrix-synapse.restore.stopOnRestore = [ "matrix-synapse" ];
clan.core.vars.generators =
{

View File

@@ -1,9 +1,224 @@
{ lib, ... }:
{
imports = [
(lib.mkRemovedOptionModule [
"clan"
"postgresql"
] "The postgresql module has been migrated to a clan core option. Use clan.core.postgresql instead")
];
pkgs,
lib,
config,
...
}:
let
createDatabaseState =
db:
let
folder = "/var/backup/postgres/${db.name}";
current = "${folder}/pg-dump";
compression = lib.optionalString (lib.versionAtLeast config.services.postgresql.package.version "16") "--compress=zstd";
in
{
folders = [ folder ];
preBackupScript = ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
mkdir -p "${folder}"
runuser -u postgres -- pg_dump ${compression} --dbname=${db.name} -Fc -c > "${current}.tmp"
mv "${current}.tmp" ${current}
'';
postRestoreScript = ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
pkgs.gnugrep
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
echo "Waiting for postgres to be ready..."
while ! runuser -u postgres -- psql --port=${builtins.toString config.services.postgresql.settings.port} -d postgres -c "" ; do
if ! systemctl is-active postgresql; then exit 1; fi
sleep 0.1
done
if [[ -e "${current}" ]]; then
(
systemctl stop ${lib.concatStringsSep " " db.restore.stopOnRestore}
trap "systemctl start ${lib.concatStringsSep " " db.restore.stopOnRestore}" EXIT
mkdir -p "${folder}"
if runuser -u postgres -- psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${db.name}'" | grep -q 1; then
runuser -u postgres -- dropdb "${db.name}"
fi
runuser -u postgres -- pg_restore -C -d postgres "${current}"
)
else
echo No database backup found, skipping restore
fi
'';
};
createDatabase = db: ''
CREATE DATABASE "${db.name}" ${
lib.concatStringsSep " " (
lib.mapAttrsToList (name: value: "${name} = '${value}'") db.create.options
)
}
'';
cfg = config.clan.postgresql;
userClauses = lib.mapAttrsToList (
_: user:
''$PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname='${user.name}'" | grep -q 1 || $PSQL -tAc 'CREATE USER "${user.name}"' ''
) cfg.users;
databaseClauses = lib.mapAttrsToList (
name: db:
lib.optionalString db.create.enable ''$PSQL -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${name}'" | grep -q 1 || $PSQL -d postgres -c ${lib.escapeShellArg (createDatabase db)} ''
) cfg.databases;
in
{
options.clan.postgresql = {
# we are reimplemeting ensureDatabase and ensureUser options here to allow to create databases with options
databases = lib.mkOption {
description = "Databases to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
description = "Database name.";
};
service = lib.mkOption {
type = lib.types.str;
default = name;
description = "Service name that we associate with the database.";
};
# set to false, in case the upstream module uses ensureDatabase option
create.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Create the database if it does not exist.";
};
create.options = lib.mkOption {
description = "Options to pass to the CREATE DATABASE command.";
type = lib.types.lazyAttrsOf lib.types.str;
default = { };
example = {
TEMPLATE = "template0";
LC_COLLATE = "C";
LC_CTYPE = "C";
ENCODING = "UTF8";
OWNER = "foo";
};
};
restore.stopOnRestore = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of systemd services to stop before restoring the database.";
};
};
}
)
);
};
users = lib.mkOption {
description = "Users to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options.name = lib.mkOption {
description = "User name";
type = lib.types.str;
default = name;
};
}
)
);
};
};
config = {
services.postgresql.settings = {
wal_level = "replica";
max_wal_senders = 3;
};
services.postgresql.enable = true;
# We are duplicating a bit the upstream module but allow to create databases with options
systemd.services.postgresql.postStart = ''
PSQL="psql --port=${builtins.toString config.services.postgresql.settings.port}"
while ! $PSQL -d postgres -c "" 2> /dev/null; do
if ! kill -0 "$MAINPID"; then exit 1; fi
sleep 0.1
done
${lib.concatStringsSep "\n" userClauses}
${lib.concatStringsSep "\n" databaseClauses}
'';
clan.core.state = lib.mapAttrs' (
_: db: lib.nameValuePair db.service (createDatabaseState db)
) config.clan.postgresql.databases;
environment.systemPackages = builtins.map (
db:
let
folder = "/var/backup/postgres/${db.name}";
current = "${folder}/pg-dump";
in
pkgs.writeShellScriptBin "postgres-db-restore-command-${db.name}" ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
pkgs.gnugrep
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
echo "Waiting for postgres to be ready..."
while ! runuser -u postgres -- psql --port=${builtins.toString config.services.postgresql.settings.port} -d postgres -c "" ; do
if ! systemctl is-active postgresql; then exit 1; fi
sleep 0.1
done
if [[ -e "${current}" ]]; then
(
${lib.optionalString (db.restore.stopOnRestore != [ ]) ''
systemctl stop ${builtins.toString db.restore.stopOnRestore}
trap "systemctl start ${builtins.toString db.restore.stopOnRestore}" EXIT
''}
mkdir -p "${folder}"
if runuser -u postgres -- psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${db.name}'" | grep -q 1; then
runuser -u postgres -- dropdb "${db.name}"
fi
runuser -u postgres -- pg_restore -C -d postgres "${current}"
)
else
echo No database backup found, skipping restore
fi
''
) (builtins.attrValues config.clan.postgresql.databases);
};
}

View File

@@ -12,7 +12,7 @@ After the system was installed/deployed the following command can be used to dis
clan vars get [machine_name] root-password/root-password
```
See also: [Vars](../../concepts/generators.md)
See also: [Vars](../../guides/vars-backend.md)
To regenerate the password run:
```

View File

@@ -16,7 +16,7 @@ After the system was installed/deployed the following command can be used to dis
clan vars get [machine_name] root-password/root-password
```
See also: [Vars](../../concepts/generators.md)
See also: [Vars](../../guides/vars-backend.md)
To regenerate the password run:
```

View File

@@ -10,6 +10,7 @@ in
{
imports = [
../postgresql
(lib.mkRemovedOptionModule [
"clan"
"vaultwarden"
@@ -56,17 +57,15 @@ in
config = {
clan.core.postgresql.enable = true;
clan.core.postgresql.users.vaultwarden = { };
clan.core.postgresql.databases.vaultwarden.create.options = {
clan.postgresql.users.vaultwarden = { };
clan.postgresql.databases.vaultwarden.create.options = {
TEMPLATE = "template0";
LC_COLLATE = "C";
LC_CTYPE = "C";
ENCODING = "UTF8";
OWNER = "vaultwarden";
};
clan.core.postgresql.databases.vaultwarden.restore.stopOnRestore = [ "vaultwarden" ];
clan.postgresql.databases.vaultwarden.restore.stopOnRestore = [ "vaultwarden" ];
services.nginx = {
enable = true;

View File

@@ -1,59 +1,9 @@
## Usage
BorgBackup (short: Borg) gives you:
```nix
inventory.instances = {
borgbackup = {
module = {
name = "borgbackup";
input = "clan";
};
roles.client.machines."jon".settings = {
destinations."storagebox" = {
repo = "username@$hostname:/./borgbackup";
rsh = ''ssh -oPort=23 -i /run/secrets/vars/borgbackup/borgbackup.ssh'';
};
};
roles.server.machines = { };
};
};
```
The input should be named according to your flake input. Jon is configured as a
client machine with a destination pointing to a Hetzner Storage Box.
## Overview
This guide explains how to set up and manage
[BorgBackup](https://borgbackup.readthedocs.io/) for secure, efficient backups
in a clan network. BorgBackup provides:
- Space efficient storage of backups with deduplication
- Secure, authenticated encryption
- Compression: lz4, zstd, zlib, lzma or none
- Mountable backups with FUSE
- Space efficient storage of backups.
- Secure, authenticated encryption.
- Compression: lz4, zstd, zlib, lzma or none.
- Mountable backups with FUSE.
- Easy installation on multiple platforms: Linux, macOS, BSD, …
- Free software (BSD license).
- Backed by a large and active open-source community.
## Roles
### 1. Client
Clients are machines that create and send backups to various destinations. Each
client can have multiple backup destinations configured.
### 2. Server
Servers act as backup repositories, receiving and storing backups from client
machines. They can be dedicated backup servers within your clan network.
## Backup destinations
This service allows you to perform backups to multiple `destinations`.
Destinations can be:
- **Local**: Local disk storage
- **Server**: Your own borgbackup server (using the `server` role)
- **Third-party services**: Such as Hetzner's Storage Box
For a more comprehensive guide on backups look into the guide section.

View File

@@ -55,40 +55,29 @@ nav:
- Add Services: guides/getting-started/add-services.md
- Deploy Machine: guides/getting-started/deploy.md
- Continuous Integration: guides/getting-started/check.md
- Inventory: guides/inventory.md
- Using Services: guides/clanServices.md
- Backup & Restore: guides/backups.md
- Disk Encryption: guides/disk-encryption.md
- Vars: guides/vars-backend.md
- Age Plugins: guides/age-plugins.md
- Secrets management: guides/secrets.md
- Advanced Secrets: guides/secrets.md
- Machine Autoincludes: guides/more-machines.md
- Target Host: guides/target-host.md
- Zerotier VPN: guides/mesh-vpn.md
- Secure Boot: guides/secure-boot.md
- Flake-parts: guides/flake-parts.md
- macOS: guides/macos.md
- Contributing:
- Contributing: guides/contributing/CONTRIBUTING.md
- Debugging: guides/contributing/debugging.md
- Testing: guides/contributing/testing.md
- Writing a Service Module: guides/services/community.md
- Writing a Disko Template: guides/disko-templates/community.md
- Migrations:
- Migrate existing Flakes: guides/migrations/migration-guide.md
- Migrate inventory Services: guides/migrations/migrate-inventory-services.md
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
- Disk id: guides/migrations/disk-id.md
- Concepts:
- Inventory: concepts/inventory.md
- Generators: concepts/generators.md
- Autoincludes: concepts/autoincludes.md
- Templates: concepts/templates.md
- macOS: guides/macos.md
- Reference:
- Overview: reference/index.md
- Clan Options: options.md
- Services:
- Overview:
- reference/clanServices/index.md
- List:
- Overview: reference/clanServices/index.md
- reference/clanServices/admin.md
- reference/clanServices/borgbackup.md
- reference/clanServices/data-mesher.md
@@ -105,7 +94,66 @@ nav:
- reference/clanServices/wifi.md
- reference/clanServices/zerotier.md
- API: reference/clanServices/clan-service-author-interface.md
- Writing a Service Module: developer/extensions/clanServices/index.md
- Modules:
- List:
- Overview: reference/clanModules/index.md
- reference/clanModules/frontmatter/index.md
# TODO: display the docs of the clan.service modules
- reference/clanModules/admin.md
# This is the module overview and should stay at the top
- reference/clanModules/borgbackup-static.md
- reference/clanModules/data-mesher.md
- reference/clanModules/borgbackup.md
- reference/clanModules/deltachat.md
- reference/clanModules/disk-id.md
- reference/clanModules/dyndns.md
- reference/clanModules/ergochat.md
- reference/clanModules/garage.md
- reference/clanModules/heisenbridge.md
- reference/clanModules/importer.md
- reference/clanModules/iwd.md
- reference/clanModules/localbackup.md
- reference/clanModules/localsend.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/moonlight.md
- reference/clanModules/mumble.md
- reference/clanModules/mycelium.md
- reference/clanModules/nginx.md
- reference/clanModules/packages.md
- reference/clanModules/postgresql.md
- reference/clanModules/root-password.md
- reference/clanModules/single-disk.md
- reference/clanModules/sshd.md
- reference/clanModules/state-version.md
- reference/clanModules/static-hosts.md
- reference/clanModules/sunshine.md
- reference/clanModules/syncthing-static-peers.md
- reference/clanModules/syncthing.md
- reference/clanModules/thelounge.md
- reference/clanModules/trusted-nix-caches.md
- reference/clanModules/user-password.md
- reference/clanModules/auto-upgrade.md
- reference/clanModules/vaultwarden.md
- reference/clanModules/xfce.md
- reference/clanModules/zerotier-static-peers.md
- reference/clanModules/zerotier.md
- reference/clanModules/zt-tcp-relay.md
- Writing a Clan Module: developer/extensions/clanModules/index.md
- Nix API:
- inputs.clan-core.lib.clan: reference/nix-api/clan.md
- config.clan.core:
- Overview: reference/clan.core/index.md
- reference/clan.core/backups.md
- reference/clan.core/deployment.md
- reference/clan.core/facts.md
- reference/clan.core/networking.md
- reference/clan.core/settings.md
- reference/clan.core/sops.md
- reference/clan.core/state.md
- reference/clan.core/vars.md
- Inventory: reference/nix-api/inventory.md
- CLI:
- Overview: reference/cli/index.md
@@ -122,64 +170,8 @@ nav:
- reference/cli/templates.md
- reference/cli/vars.md
- reference/cli/vms.md
- Modules (deprecated):
- Overview: reference/clanModules/index.md
- reference/clanModules/frontmatter/index.md
# TODO: display the docs of the clan.service modules
- reference/clanModules/admin.md
# This is the module overview and should stay at the top
- reference/clanModules/borgbackup-static.md
- reference/clanModules/data-mesher.md
- reference/clanModules/borgbackup.md
- reference/clanModules/deltachat.md
- reference/clanModules/disk-id.md
- reference/clanModules/dyndns.md
- reference/clanModules/ergochat.md
- reference/clanModules/garage.md
- reference/clanModules/heisenbridge.md
- reference/clanModules/importer.md
- reference/clanModules/iwd.md
- reference/clanModules/localbackup.md
- reference/clanModules/localsend.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/moonlight.md
- reference/clanModules/mumble.md
- reference/clanModules/mycelium.md
- reference/clanModules/nginx.md
- reference/clanModules/packages.md
- reference/clanModules/postgresql.md
- reference/clanModules/root-password.md
- reference/clanModules/single-disk.md
- reference/clanModules/sshd.md
- reference/clanModules/state-version.md
- reference/clanModules/static-hosts.md
- reference/clanModules/sunshine.md
- reference/clanModules/syncthing-static-peers.md
- reference/clanModules/syncthing.md
- reference/clanModules/thelounge.md
- reference/clanModules/trusted-nix-caches.md
- reference/clanModules/user-password.md
- reference/clanModules/auto-upgrade.md
- reference/clanModules/vaultwarden.md
- reference/clanModules/xfce.md
- reference/clanModules/zerotier-static-peers.md
- reference/clanModules/zerotier.md
- reference/clanModules/zt-tcp-relay.md
- clan.core (NixOS Options):
- Overview: reference/clan.core/index.md
- reference/clan.core/backups.md
- reference/clan.core/deployment.md
- reference/clan.core/facts.md
- reference/clan.core/networking.md
- reference/clan.core/postgresql.md
- reference/clan.core/settings.md
- reference/clan.core/sops.md
- reference/clan.core/state.md
- reference/clan.core/vars.md
- Developer-api: api.md
- Glossary: reference/glossary.md
- Decisions:
- Architecture Decisions: decisions/README.md
- 01-clanModules: decisions/01-ClanModules.md
@@ -188,7 +180,16 @@ nav:
- 04-fetching-nix-from-python: decisions/04-fetching-nix-from-python.md
- 05-deployment-parameters: decisions/05-deployment-parameters.md
- Template: decisions/_template.md
- Glossary: reference/glossary.md
- Options: options.md
- Developer:
- Introduction: developer/index.md
- Dev Setup: developer/contributing/CONTRIBUTING.md
- Writing a Service Module: developer/extensions/clanServices/index.md
- Writing a Clan Module: developer/extensions/clanModules/index.md
- Writing a Disko Template: developer/extensions/templates/disk/disko-templates.md
- Debugging: developer/contributing/debugging.md
- Testing: developer/contributing/testing.md
- Python API: developer/api.md
docs_dir: site
site_dir: out
@@ -199,7 +200,6 @@ theme:
favicon: https://clan.lol/favicon.svg
name: material
features:
- navigation.footer
- navigation.instant
- navigation.tabs
- navigation.tabs.sticky
@@ -249,4 +249,4 @@ plugins:
- redoc-tag
- redirects:
redirect_maps:
guides/getting-started/secrets.md: concepts/generators.md
guides/getting-started/secrets.md: guides/vars-backend.md

View File

@@ -55,7 +55,6 @@ pkgs.stdenv.mkDerivation {
chmod -R +w ./site/reference
echo "Generated API documentation in './site/reference/' "
rm -r ./site/options-page || true
cp -r ${docs-options} ./site/options-page
chmod -R +w ./site/options-page

View File

@@ -193,7 +193,7 @@ def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
def module_nix_usage(module_name: str) -> str:
return f"""## Usage via Nix
**This module can be also imported directly in your nixos configuration. Although it is recommended to use the [inventory](../../concepts/inventory.md) interface if available.**
**This module can be also imported directly in your nixos configuration. Although it is recommended to use the [inventory](../../reference/nix-api/inventory.md) interface if available.**
Some modules are considered 'low-level' or 'expert modules' and are not available via the inventory interface.
@@ -373,7 +373,7 @@ This module can be used via predefined roles
"""
Every role has its own configuration options, which are each listed below.
For more information, see the [inventory guide](../../concepts/inventory.md).
For more information, see the [inventory guide](../../guides/inventory.md).
??? Example
For example the `admin` module adds the following options globally to all machines where it is used.
@@ -402,7 +402,7 @@ certain option types restricted to enable configuration through a graphical
interface.
!!! note "🔹"
Modules with this indicator support the [inventory](../../concepts/inventory.md) feature.
Modules with this indicator support the [inventory](../../guides/inventory.md) feature.
"""
@@ -679,6 +679,86 @@ def build_option_card(module_name: str, frontmatter: Frontmatter) -> str:
return f"{to_md_li(module_name, frontmatter)}\n\n"
def produce_build_clan_docs() -> None:
if not BUILD_CLAN_PATH:
msg = f"Environment variables are not set correctly: BUILD_CLAN_PATH={BUILD_CLAN_PATH}. Expected a path to the optionsJSON"
raise ClanError(msg)
if not OUT:
msg = f"Environment variables are not set correctly: $out={OUT}"
raise ClanError(msg)
output = """# Clan
This provides an overview of the available arguments of the `clan` interface.
Each attribute is documented below
- **clan-core.lib.clan**: A function that takes an attribute set.
??? example "clan Example"
```nix
clan {
self = self;
machines = {
jon = { };
sara = { };
};
};
```
- **clan with flake-parts**: Import the FlakeModule
After importing the FlakeModule you can define your `clan` as a flake attribute
All attribute can be defined via `clan.*`
Further information see: [flake-parts](../../guides/flake-parts.md) guide.
??? example "flake-parts Example"
```nix
flake-parts.lib.mkFlake { inherit inputs; } ({
systems = [];
imports = [
clan-core.flakeModules.default
];
clan = {
machines = {
jon = { };
sara = { };
};
};
});
```
"""
with Path(BUILD_CLAN_PATH).open() as f:
options: dict[str, dict[str, Any]] = json.load(f)
split = split_options_by_root(options)
for option_name, options in split.items():
# Skip underscore options
if option_name.startswith("_"):
continue
# Skip inventory sub options
# Inventory model has its own chapter
if option_name.startswith("inventory."):
continue
print(f"[build_clan_docs] Rendering option of {option_name}...")
root = options_to_tree(options)
for option in root.suboptions:
output += options_docs_from_tree(option, init_level=2)
outfile = Path(OUT) / "nix-api/clan.md"
outfile.parent.mkdir(parents=True, exist_ok=True)
with Path.open(outfile, "w") as of:
of.write(output)
def split_options_by_root(options: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""
Split the flat dictionary of options into a dict of which each entry will construct complete option trees.
@@ -725,7 +805,7 @@ Typically needed by module authors to define roles, behavior and metadata for di
!!! Note
This is not a user-facing documentation, but rather meant as a reference for *module authors*
See: [clanService Authoring Guide](../../guides/services/community.md)
See: [clanService Authoring Guide](../../developer/extensions/clanServices/index.md)
"""
# Inventory options are already included under the clan attribute
# We just omitted them in the clan docs, because we want a separate output for the inventory model
@@ -754,6 +834,48 @@ class Option:
suboptions: list["Option"] = field(default_factory=list)
def produce_inventory_docs() -> None:
if not BUILD_CLAN_PATH:
msg = f"Environment variables are not set correctly: BUILD_CLAN_PATH={BUILD_CLAN_PATH}. Expected a path to the optionsJSON"
raise ClanError(msg)
if not OUT:
msg = f"Environment variables are not set correctly: $out={OUT}"
raise ClanError(msg)
output = """# Inventory
This provides an overview of the available attributes of the `inventory` model.
It can be set via the `inventory` attribute of the [`clan`](./clan.md#inventory) function, or via the [`clan.inventory`](./clan.md#inventory) attribute of flake-parts.
"""
# Inventory options are already included under the clan attribute
# We just omitted them in the clan docs, because we want a separate output for the inventory model
with Path(BUILD_CLAN_PATH).open() as f:
options: dict[str, dict[str, Any]] = json.load(f)
clan_root_option = options_to_tree(options)
# Find the inventory options
inventory_opt: None | Option = None
for opt in clan_root_option.suboptions:
if opt.name == "inventory":
inventory_opt = opt
break
if not inventory_opt:
print("No inventory options found.")
exit(1)
# Render the inventory options
# This for loop excludes the root node
for option in inventory_opt.suboptions:
output += options_docs_from_tree(option, init_level=2)
outfile = Path(OUT) / "nix-api/inventory.md"
outfile.parent.mkdir(parents=True, exist_ok=True)
with Path.open(outfile, "w") as of:
of.write(output)
def option_short_name(option_name: str) -> str:
parts = option_name.split(".")
short_name = ""
@@ -862,6 +984,9 @@ def options_docs_from_tree(
if __name__ == "__main__": #
produce_clan_core_docs()
produce_build_clan_docs()
produce_inventory_docs()
produce_clan_service_author_docs()
produce_clan_modules_docs()

View File

@@ -1,15 +0,0 @@
Clan automatically imports the following files from a directory and registers them.
## Machine registration
Every folder `machines/{machineName}` will be registered automatically as a Clan machine.
!!! info "Automatically loaded files"
The following files are loaded automatically for each Clan machine:
- [x] `machines/{machineName}/configuration.nix`
- [x] `machines/{machineName}/hardware-configuration.nix`
- [x] `machines/{machineName}/facter.json` Automatically configured, for further information see [nixos-facter](https://clan.lol/blog/nixos-facter/)
- [x] `machines/{machineName}/disko.nix` Automatically loaded, for further information see the [disko docs](https://github.com/nix-community/disko/blob/master/docs/quickstart.md).

View File

@@ -1,69 +0,0 @@
# How Templates work
Clan offers the ability to use templates for creating different resources.
It comes with some `<builtin>` templates and discovers all exposed templates from its flake's `inputs`
For example one can list all current templates like this:
```shellSession
$ clan templates list
Available 'clan' templates
├── <builtin>
│ ├── default: Initialize a new clan flake
│ ├── flake-parts: Flake-parts
│ └── minimal: for clans managed via (G)UI
└── inputs.self:
├── default: Initialize a new clan flake
├── flake-parts: Flake-parts
└── minimal: for clans managed via (G)UI
Available 'disko' templates
├── <builtin>
│ └── single-disk: A simple ext4 disk with a single partition
└── inputs.self:
└── single-disk: A simple ext4 disk with a single partition
Available 'machine' templates
├── <builtin>
│ ├── demo-template: Demo machine for the CLAN project
│ ├── flash-installer: Initialize a new flash-installer machine
│ ├── new-machine: Initialize a new machine
│ └── test-morph-template: Morph a machine
└── inputs.self:
├── demo-template: Demo machine for the CLAN project
├── flash-installer: Initialize a new flash-installer machine
├── new-machine: Initialize a new machine
└── test-morph-template: Morph a machine
```
## Using `<builtin>` Templates
Templates are referenced via the `--template` `selector`
clan-core ships its native/builtin templates. Those are referenced if the selector is a plain string ( without `#` or `./.` )
For example:
`clan flakes create --template=flake-parts`
would use the native `<builtin>.flake-parts` template
## Selectors follow nix flake `reference#attribute` syntax
Selectors follow a very similar pattern as Nix's native attribute selection behavior.
Just like `nix build .` would build `packages.x86-linux.default` of the flake in `./.`
`clan flakes create --template=.` would create a clan from your **local** `default` clan template (`templates.clan.default`).
In fact this command would be equivalent, just make it more explicit
`clan flakes create --template=.#clan.templates.clan.default` (explicit path)
## Remote templates
Just like with Nix you could specify a remote url or path to the flake containing the template
`clan flakes create --template=github:owner/repo#foo`
!!! Note "Implementation Note"
Not all features of Nix's attribute selection are currently matched.
There are minor differences in case of unexpected behavior please create an [issue](https://git.clan.lol/clan/clan-core/issues/new)

View File

@@ -6,8 +6,6 @@ Accepted
## Context
Current state as of writing:
To define a service in Clan, you need to define two things:
- `clanModule` - defined by module authors

View File

@@ -0,0 +1,229 @@
# Authoring a clanModule
!!! Danger "Will get deprecated soon"
Please consider twice creating new modules in this format
[`clan.service` module](../clanServices/index.md) will be the new standard soon.
This site will guide you through authoring your first module. Explaining which conventions must be followed, such that others will have an enjoyable experience and the module can be used with minimal effort.
!!! Tip
External ClanModules can be ad-hoc loaded via [`clan.inventory.modules`](../../../reference/nix-api/inventory.md#inventory.modules)
## Bootstrapping the `clanModule`
A ClanModule is a specific subset of a [NixOS Module](https://nix.dev/tutorials/module-system/index.html), but it has some constraints and might be used via the [Inventory](../../../guides/inventory.md) interface.
In fact a `ClanModule` can be thought of as a layer of abstraction on-top of NixOS and/or other ClanModules. It may configure sane defaults and provide an ergonomic interface that is easy to use and can also be used via a UI that is under development currently.
Because ClanModules should be configurable via `json`/`API` all of its interface (`options`) must be serializable.
!!! Tip
ClanModules interface can be checked by running the json schema converter as follows.
`nix build .#legacyPackages.x86_64-linux.schemas.inventory`
If the build succeeds the module is compatible.
## Directory structure
Each module SHOULD be a directory of the following format:
```sh
# Example: borgbackup
clanModules/borgbackup
├── README.md
└── roles
├── client.nix
└── server.nix
```
!!! Tip
`README.md` is always required. See section [Readme](#readme) for further details.
The `roles` folder is strictly required for `features = [ "inventory" ]`.
## Registering the module
=== "User module"
If the module should be ad-hoc loaded.
It can be made available in any project via the [`clan.inventory.modules`](../../../reference/nix-api/inventory.md#inventory.modules) attribute.
```nix title="flake.nix"
# ...
# Sometimes this attribute set is defined in clan.nix
clan-core.lib.clan {
# 1. Add the module to the available clanModules with inventory support
inventory.modules = {
custom-module = ./modules/my_module;
};
# 2. Use the module in the inventory
inventory.services = {
custom-module.instance_1 = {
roles.default.machines = [ "machineA" ];
};
};
};
```
=== "Upstream module"
If the module will be contributed to [`clan-core`](https://git.clan.lol/clan-core)
The clanModule must be registered within the `clanModules` attribute in `clan-core`
```nix title="clanModules/flake-module.nix"
--8<-- "clanModules/flake-module.nix:0:5"
# Register our new module here
# ...
```
## Readme
The `README.md` is a required file for all modules. It MUST contain frontmatter in [`toml`](https://toml.io) format.
```markdown
---
description = "Module A"
---
This is the example module that does xyz.
```
See the [Full Frontmatter reference](../../../reference/clanModules/frontmatter/index.md) further details and all supported attributes.
## Roles
If the module declares to implement `features = [ "inventory" ]` then it MUST contain a roles directory.
Each `.nix` file in the `roles` directory is added as a role to the inventory service.
Other files can also be placed alongside the `.nix` files
```sh
└── roles
├── client.nix
└── server.nix
```
Adds the roles: `client` and `server`
??? Tip "Good to know"
Sometimes a `ClanModule` should be usable via both clan's `inventory` concept but also natively as a NixOS module.
> In the long term, we want most modules to implement support for the inventory,
> but we are also aware that there are certain low-level modules that always serve as a backend for other higher-level `clanModules` with inventory support.
> These modules may not want to implement inventory interfaces as they are always used directly by other modules.
This can be achieved by placing an additional `default.nix` into the root of the ClanModules directory as shown:
```sh
# ModuleA
├── README.md
├── default.nix
└── roles
└── default.nix
```
```nix title="default.nix"
{...}:{
imports = [ ./roles/default.nix ];
}
```
By utilizing this pattern the module (`moduleA`) can then be imported into any regular NixOS module via:
```nix
{...}:{
imports = [ clanModules.moduleA ];
}
```
## Adding configuration options
While we recommend to keep the interface as minimal as possible and deriving all required information from the `roles` model it might sometimes be required or convenient to expose customization options beyond `roles`.
The following shows how to add options to your module.
**It is important to understand that every module has its own namespace where it should declare options**
**`clan.{moduleName}`**
???+ Example
The following example shows how to register options in the module interface
and how it can be set via the inventory
```nix title="/default.nix"
custom-module = ./modules/custom-module;
```
Since the module is called `custom-module` all of its exposed options should be added to `options.clan.custom-module.*...*`
```nix title="custom-module/roles/default.nix"
{
options = {
clan.custom-module.foo = mkOption {
type = types.str;
default = "bar";
};
};
}
```
If the module is [registered](#registering-the-module).
Configuration can be set as follows.
```nix title="flake.nix"
# Sometimes this attribute set is defined in clan.nix
clan-core.lib.clan {
inventory.services = {
custom-module.instance_1 = {
roles.default.machines = [ "machineA" ];
roles.default.config = {
# All configuration here is scoped to `clan.custom-module`
foo = "foobar";
};
};
};
}
```
## Organizing the ClanModule
Each `{role}.nix` is included into the machine if the machine is declared to have the role.
For example
```nix
roles.client.machines = ["MachineA"];
```
Then `roles/client.nix` will be added to the machine `MachineA`.
This behavior makes it possible to split the interface and common code paths when using multiple roles.
In the concrete example of `borgbackup` this allows a `server` to declare a different interface than the corresponding `client`.
The client offers configuration option, to exclude certain local directories from being backed up:
```nix title="roles/client.nix"
# Example client interface
options.clan.borgbackup.exclude = ...
```
The server doesn't offer any configuration option. Because everything is set-up automatically.
```nix title="roles/server.nix"
# Example server interface
options.clan.borgbackup = {};
```
Assuming that there is a common code path or a common interface between `server` and `client` this can be structured as:
```nix title="roles/server.nix, roles/client.nix"
{...}: {
# ...
imports = [ ../common.nix ];
}
```

View File

@@ -1,16 +1,16 @@
# Authoring a 'clan.service' module
!!! Tip
This is the successor format to the older [clanModules](../../reference/clanModules/index.md)
This is the successor format to the older [clanModules](../clanModules/index.md)
While some features might still be missing we recommend to adapt this format early and give feedback.
## Service Module Specification
This section explains how to author a clan service module.
We discussed the initial architecture in [01-clan-service-modules](../../decisions/01-ClanModules.md) and decided to rework the format.
We discussed the initial architecture in [01-clan-service-modules](../../../decisions/01-ClanModules.md) and decided to rework the format.
For the full specification and current state see: **[Service Author Reference](../../reference/clanServices/clan-service-author-interface.md)**
For the full specification and current state see: **[Service Author Reference](../../../reference/clanServices/clan-service-author-interface.md)**
### A Minimal module
@@ -52,7 +52,7 @@ The imported module file must fulfill at least the following requirements:
}
```
For more attributes see: **[Service Author Reference](../../reference/clanServices/clan-service-author-interface.md)**
For more attributes see: **[Service Author Reference](../../../reference/clanServices/clan-service-author-interface.md)**
### Adding functionality to the module
@@ -266,6 +266,6 @@ The benefit of this approach is that downstream users can override the value of
## Further
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
- [Decision that lead to ClanServices](../../decisions/01-ClanModules.md)
- [Reference Documentation for Service Authors](../../../reference/clanServices/clan-service-author-interface.md)
- [Migration Guide from ClanModules to ClanServices](../../../guides/migrations/migrate-inventory-services.md)
- [Decision that lead to ClanServices](../../../decisions/01-ClanModules.md)

View File

@@ -0,0 +1,25 @@
# Developer Documentation
!!! Danger
This documentation is **not** intended for external users. It may contain low-level details and internal-only interfaces.*
Welcome to the internal developer documentation.
This section is intended for contributors, engineers, and internal stakeholders working directly with our system, tooling, and APIs. It provides a technical overview of core components, internal APIs, conventions, and patterns that support the platform.
Our goal is to make the internal workings of the system **transparent, discoverable, and consistent** — helping you contribute confidently, troubleshoot effectively, and build faster.
## What's Here?
!!! note "docs migration ongoing"
- [ ] **API Reference**: 🚧🚧🚧 Detailed documentation of internal API functions, inputs, and expected outputs. 🚧🚧🚧
- [ ] **System Concepts**: Architectural overviews and domain-specific guides.
- [ ] **Development Guides**: How to test, extend, or integrate with key components.
- [ ] **Design Notes**: Rationales behind major design decisions or patterns.
## Who is This For?
* Developers contributing to the platform
* Engineers debugging or extending internal systems
* Anyone needing to understand **how** and **why** things work under the hood

View File

@@ -1,199 +1,167 @@
# Introduction to Backups
This guide explains how to set up and manage
[BorgBackup](https://borgbackup.readthedocs.io/) for secure, efficient backups
in a clan network. BorgBackup provides:
When you're managing your own services, creating regular backups is crucial to ensure your data's safety.
This guide introduces you to Clan's built-in backup functionalities.
Clan supports backing up your data to both local storage devices (like USB drives) and remote servers, using well-known tools like borgbackup and rsnapshot.
We might add more options in the future, but for now, let's dive into how you can secure your data.
- Space efficient storage of backups with deduplication
- Secure, authenticated encryption
- Compression: lz4, zstd, zlib, lzma or none
- Mountable backups with FUSE
- Easy installation on multiple platforms: Linux, macOS, BSD, …
- Free software (BSD license).
- Backed by a large and active open-source community.
## Backing Up Locally with Localbackup
## Borgbackup Example
Localbackup lets you backup your data onto physical storage devices connected to your computer,
such as USB hard drives or network-attached storage. It uses a tool called rsnapshot for this purpose.
### Setting Up Localbackup
1. **Identify Your Backup Device:**
First, figure out which device you'll use for backups. You can see all connected devices by running this command in your terminal:
```bash
lsblk --output NAME,PTUUID,FSTYPE,SIZE,MOUNTPOINT
```
Look for the device you intend to use for backups and note its details.
2. **Configure Your Backup Device:**
Once you've identified your device, you'll need to add it to your configuration.
Here's an example NixOS configuration for a device located at `/dev/sda2` with an `ext4` filesystem:
```nix
inventory.instances = {
borgbackup = {
module = {
name = "borgbackup";
input = "clan";
};
roles.client.machines."jon".settings = {
destinations."storagebox" = {
repo = "username@$hostname:/./borgbackup";
rsh = ''ssh -oPort=23 -i /run/secrets/vars/borgbackup/borgbackup.ssh'';
};
};
roles.server.machines = { };
{
fileSystems."/mnt/hdd" = {
device = "/dev/sda2";
fsType = "ext4";
options = [ "defaults" "noauto" ];
};
};
}
```
The input should be named according to your flake input. Jon is configured as a
client machine with a destination pointing to a Hetzner Storage Box.
Replace `/dev/sda2` with your device and `/mnt/hdd` with your preferred mount point.
To see a list of all possible options go to [borgbackup clan service](../reference/clanServices/borgbackup.md)
3. **Set Backup Targets:** Next, define where on your device you'd like the backups to be stored:
## Roles
```nix
{
clan.localbackup.targets.hdd = {
directory = "/mnt/hdd/backup";
mountpoint = "/mnt/hdd";
};
}
```
A Clan Service can have multiple roles, each role applies different nix config to the machine.
Change `/mnt/hdd` to the actual mount point you're using.
### 1. Client
4. **Create Backups:** To create a backup, run:
Clients are machines that create and send backups to various destinations. Each
client can have multiple backup destinations configured.
```bash
clan backups create mymachine
```
### 2. Server
This command saves snapshots of your data onto the backup device.
Servers act as backup repositories, receiving and storing backups from client
machines. They can be dedicated backup servers within your clan network.
5. **Listing Backups:** To see available backups, run:
## Backup destinations
```bash
clan backups list mymachine
```
This service allows you to perform backups to multiple `destinations`.
Destinations can be:
## Remote Backups with Borgbackup
- **Local**: Local disk storage
- **Server**: Your own borgbackup server (using the `server` role)
- **Third-party services**: Such as Hetzner's Storage Box
### Overview of Borgbackup
## State management
Borgbackup splits the backup process into two parts: a backup client that sends data to a backup server.
The server stores the backups.
Backups are based on [states](../reference/clan.core/state.md). A state
defines which files should be backed up and how these files are obtained through
pre/post backup and restore scripts.
### Setting Up the Borgbackup Client
Here's an example for a user application `linkding`:
1. **Specify Backup Server:**
In this example:
- `/data/podman/linkding` is the application's data directory
- `/var/backup/linkding` is the staging directory where data is copied for
backup
Start by indicating where your backup data should be sent. Replace `hostname` with your server's address:
```nix
clan.core.state.linkding = {
folders = [ "/var/backup/linkding" ];
preBackupScript = ''
export PATH=${
lib.makeBinPath [
config.systemd.package
pkgs.coreutils
pkgs.rsync
]
}
service_status=$(systemctl is-active podman-linkding)
if [ "$service_status" = "active" ]; then
systemctl stop podman-linkding
rsync -avH --delete --numeric-ids "/data/podman/linkding/" /var/backup/linkding/
systemctl start podman-linkding
fi
'';
postRestoreScript = ''
export PATH=${
lib.makeBinPath [
config.systemd.package
pkgs.coreutils
pkgs.rsync
]
}
service_status="$(systemctl is-active podman-linkding)"
if [ "$service_status" = "active" ]; then
systemctl stop podman-linkding
# Backup locally current linkding data
cp -rp "/data/podman/linkding" "/data/podman/linkding.bak"
# Restore from borgbackup
rsync -avH --delete --numeric-ids /var/backup/linkding/ "/data/podman/linkding/"
systemctl start podman-linkding
fi
'';
};
{
clan.borgbackup.destinations = {
myhostname = {
repo = "borg@backuphost:/var/lib/borgbackup/myhostname";
};
};
}
```
## Managing backups
2. **Select Folders to Backup:**
In this section we go over how to manage your collection of backups with the clan command.
Decide which folders you want to back up. For example, to backup your home and root directories:
### Listing states
```nix
{ clan.core.state.userdata.folders = [ "/home" "/root" ]; }
```
To see which files (`states`) will be backed up on a specific machine, use:
3. **Generate Backup Credentials:**
Run `clan facts generate <yourmachine>` to prepare your machine for backup, creating necessary SSH keys and credentials.
### Setting Up the Borgbackup Server
1. **Configure Backup Repository:**
On the server where backups will be stored, enable the SSH daemon and set up a repository for each client:
```nix
{
services.borgbackup.repos.myhostname = {
path = "/var/lib/borgbackup/myhostname";
authorizedKeys = [
(builtins.readFile (config.clan.core.settings.directory + "/machines/myhostname/facts/borgbackup.ssh.pub"))
];
};
}
```
Ensure the path to the public key is correct.
2. **Update Your Systems:** Apply your changes by running `clan machines update` to both the server and your client
### Managing Backups
- **Scheduled Backups:**
Backups are automatically performed nightly. To check the next scheduled backup, use:
```bash
systemctl list-timers | grep -E 'NEXT|borg'
```
- **Listing Backups:** To see available backups, run:
```bash
clan backups list mymachine
```
- **Manual Backups:** You can also initiate a backup manually:
```bash
clan backups create mymachine
```
- **Restoring Backups:** To restore a backup that has been listed by the list command (NAME):
```bash
clan backups restore [MACHINE] [PROVIDER] [NAME]
```
Example (Restoring a machine called `client` with the backup provider `borgbackup`):
```bash
clan backups restore client borgbackup [NAME]
```
The `backups` command is service aware and allows optional specification of the `--service` flag.
To only restore the service called `zerotier` on a machine called `controller` through the backup provider `borgbackup` use the following command:
```bash
clan state list jon
clan backups restore client borgbackup [NAME] --service zerotier
```
This will show all configured states for the machine `jon`, for example:
```text
· service: linkding
folders:
- /var/backup/linkding
preBackupCommand: pre-backup-linkding
postRestoreCommand: post-restore-linkding
· service: zerotier
folders:
- /var/lib/zerotier-one
```
### Creating backups
To create a backup of a machine (e.g., `jon`), run:
```bash
clan backups create jon
```
This will backup all configured states (`zerotier` and `linkding` in this
example) from the machine `jon`.
### Listing available backups
To see all available backups, use:
```bash
clan backups list
```
This will display all backups with their timestamps:
```text
storagebox::username@username.your-storagebox.de:/./borgbackup::jon-jon-2025-07-22T19:40:10
storagebox::username@username.your-storagebox.de:/./borgbackup::jon-jon-2025-07-23T01:00:00
storagebox::username@username.your-storagebox.de:/./borgbackup::jon-storagebox-2025-07-24T01:00:00
storagebox::username@username.your-storagebox.de:/./borgbackup::jon-storagebox-2025-07-24T06:02:35
```
### Restoring backups
For restoring a backup you have two options.
#### Full restoration
To restore all services from a backup:
```bash
clan backups restore jon borgbackup storagebox::u444061@u444061.your-storagebox.de:/./borgbackup::jon-storagebox-2025-07-24T06:02:35
```
#### Partial restoration
To restore only a specific service (e.g., `linkding`):
```bash
clan backups restore --service linkding jon borgbackup storagebox::u444061@u444061.your-storagebox.de:/./borgbackup::jon-storagebox-2025-07-24T06:02:35
```

View File

@@ -138,7 +138,7 @@ You can use services exposed by Clans core module library, `clan-core`.
You can also author your own `clanService` modules.
🔗 Learn how to write your own service: [Authoring a service](../guides/services/community.md)
🔗 Learn how to write your own service: [Authoring a clanService](../developer/extensions/clanServices/index.md)
You might expose your service module from your flake — this makes it easy for other people to also use your module in their clan.
@@ -154,6 +154,6 @@ You might expose your service module from your flake — this makes it easy for
## Whats Next?
* [Author your own clanService →](../guides/services/community.md)
* [Author your own clanService →](../developer/extensions/clanServices/index.md)
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->

View File

@@ -27,7 +27,7 @@ inputs = {
## Import the Clan flake-parts Module
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../options.md) available within `mkFlake`.
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../reference/nix-api/clan.md) available within `mkFlake`.
```nix
{

View File

@@ -6,7 +6,7 @@ Machines can be added using the following methods
- Editing machines/`machine_name`/configuration.nix (automatically included if it exists)
- `clan machines create` (imperative)
See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
See the complete [list](../../guides/more-machines.md#automatic-registration) of auto-loaded files.
## Create a machine

View File

@@ -41,7 +41,7 @@ To learn more: [Guide about clanService](../clanServices.md)
```
1. See [reference/clanServices](../../reference/clanServices/index.md) for all available services and how to configure them.
Or read [authoring/clanServices](../../guides/services/community.md) if you want to bring your own
Or read [authoring/clanServices](../../developer/extensions/clanServices/index.md) if you want to bring your own
2. Replace `__YOUR_CONTROLLER_` with the *name* of your machine.

View File

@@ -9,6 +9,8 @@ The inventory logic will automatically derive the modules and configurations to
The following tutorial will walk through setting up a Backup service where the terms `Service` and `Role` will become more clear.
See also: [Inventory API Documentation](../reference/nix-api/inventory.md)
!!! example "Experimental status"
The inventory implementation is not considered stable yet.
We are actively soliciting feedback from users.
@@ -17,7 +19,7 @@ The following tutorial will walk through setting up a Backup service where the t
## Prerequisites
- [x] [Add some machines](../guides/getting-started/add-machines.md) to your Clan.
- [x] [Add multiple machines](./more-machines.md) to your Clan.
## Services

View File

@@ -7,7 +7,7 @@ This guide explains how to manage macOS machines using Clan.
Currently, Clan supports the following features for macOS:
- `clan machines update` for existing [nix-darwin](https://github.com/nix-darwin/nix-darwin) installations
- Support for [vars](../concepts/generators.md)
- Support for [vars](../guides/vars-backend.md)
## Add Your Machine to Your Clan Flake

View File

@@ -1,7 +1,7 @@
# Migrating from using `clanModules` to `clanServices`
**Audience**: This is a guide for **people using `clanModules`**.
If you are a **module author** and need to migrate your modules please consult our **new** [clanServices authoring guide](../../guides/services/community.md)
If you are a **module author** and need to migrate your modules please consult our **new** [clanServices authoring guide](../../developer/extensions/clanServices/index.md)
## What's Changing?
@@ -329,6 +329,6 @@ instances = {
## Further reference
* [Inventory Concept](../../concepts/inventory.md)
* [Authoring a 'clan.service' module](../../guides/services/community.md)
* [Authoring a 'clan.service' module](../../developer/extensions/clanServices/index.md)
* [ClanServices](../clanServices.md)
* [Inventory Reference](../../reference/nix-api/inventory.md)

View File

@@ -3,7 +3,7 @@
For a high level overview about `vars` see our [blog post](https://clan.lol/blog/vars/).
This guide will help you migrate your modules that still use our [`facts`](../../guides/secrets.md) backend
to the [`vars`](../../concepts/generators.md) backend.
to the [`vars`](../../guides/vars-backend.md) backend.
The `vars` [module](../../reference/clan.core/vars.md) and the clan [command](../../reference/cli/vars.md) work in tandem, they should ideally be kept in sync.

View File

@@ -0,0 +1,50 @@
Clan has two general methods of adding machines:
- **Automatic**: Detects every folder in the `machines` folder.
- **Declarative**: Explicit declarations in Nix.
## Automatic registration
Every folder `machines/{machineName}` will be registered automatically as a Clan machine.
!!! info "Automatically loaded files"
The following files are loaded automatically for each Clan machine:
- [x] `machines/{machineName}/configuration.nix`
- [x] `machines/{machineName}/hardware-configuration.nix`
- [x] `machines/{machineName}/facter.json` Automatically configured, for further information see [nixos-facter](https://clan.lol/blog/nixos-facter/)
- [x] `machines/{machineName}/disko.nix` Automatically loaded, for further information see the [disko docs](https://github.com/nix-community/disko/blob/master/docs/quickstart.md).
## Manual declaration
Machines can be added via [`clan.inventory.machines`](../guides/inventory.md) or in `clan.machines`, which allows for defining NixOS options.
=== "**Individual Machine Configuration**"
```{.nix}
clan-core.lib.clan {
machines = {
"jon" = {
# Any valid nixos config
};
};
}
```
=== "**Inventory Configuration**"
```{.nix}
clan-core.lib.clan {
inventory = {
machines = {
"jon" = {
# Inventory can set tags and other metadata
tags = [ "zone1" ];
deploy.targetHost = "root@jon";
};
};
};
}
```

View File

@@ -1,5 +1,5 @@
This article provides an overview over the underlying secrets system which is used by [Vars](../concepts/generators.md).
Under most circumstances you should use [Vars](../concepts/generators.md) directly instead.
This article provides an overview over the underlying secrets system which is used by [Vars](../guides/vars-backend.md).
Under most circumstances you should use [Vars](../guides/vars-backend.md) directly instead.
Consider using `clan secrets` only for managing admin users and groups, as well as a debugging tool.

View File

@@ -1,4 +1,7 @@
# Generators
!!! Note
Vars is the new secret backend that will soon replace the Facts backend
Defining a linux user's password via the nixos configuration previously required running `mkpasswd ...` and then copying the hash back into the nix configuration.
@@ -8,7 +11,7 @@ For a more general explanation of what clan vars are and how it works, see the i
This guide assumes
- Clan is set up already (see [Getting Started](../guides/getting-started/index.md))
- a machine has been added to the clan (see [Adding Machines](../guides/getting-started/add-machines.md))
- a machine has been added to the clan (see [Adding Machines](./more-machines.md))
This section will walk you through the following steps:
@@ -20,7 +23,7 @@ This section will walk you through the following steps:
6. share the root password between machines
7. change the password
## Declare a generator
## Declare the generator
In this example, a `vars` `generator` is used to:

View File

@@ -7,69 +7,98 @@ hide:
# :material-home: What is Clan?
[Clan](https://clan.lol/) is a peer-to-peer computer management framework that
empowers you to **selfhost in a reliable and scalable way**.
Built on NixOS, Clan provides a **declarative interface for managing machines** with automated [secret management](./guides/secrets.md), easy [mesh VPN
connectivity](./guides/mesh-vpn.md), and [automated backups](./guides/backups.md).
Whether you're running a homelab or maintaining critical computing infrastructure,
Clan will help **reduce maintenance burden** by allowing a **git repository to define your whole network** of computers.
In combination with [sops-nix](https://github.com/Mic92/sops-nix), [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) and [disko](https://github.com/nix-community/disko), Clan makes it possible to have **collaborative infrastructure**.
empowers you to reclaim control over your digital computing experience. Built on
NixOS, Clan provides a unified interface for managing networks of machines with
automated [secret management](./guides/secrets.md), secure [mesh VPN
connectivity](./guides/mesh-vpn.md), and customizable installation images. Whether
you're running a homelab or building decentralized computing infrastructure,
Clan simplifies configuration management while restoring your independence from
closed computing ecosystems.
At the heart of Clan are [Clan Services](./reference/clanServices/index.md) - the core
concept that enables you to add functionality across multiple machines in your
network. While Clan ships with essential core services, you can [create custom
services](./guides/clanServices.md) tailored to your specific needs.
[Getting Started](./guides/getting-started/index.md){ .md-button }
## :material-book: Guides
How-to Guides for achieving a certain goal or solving a specific issue.
**How-to Guides for achieving a certain goal or solving a specific issue.**
<div class="grid cards" markdown>
- [:material-clock-fast: Getting Started](./guides/getting-started/index.md)
- [Adding more machines](./guides/more-machines.md)
---
Get started in less than 20 minutes!
Learn how Clan automatically includes machines and Nix files.
- [Mac OS](./guides/macos.md)
- [Vars Backend](./guides/vars-backend.md)
---
How to manage Mac OS machines with Clan
Learn how to manage secrets with vars.
- [Contribute](./guides/contributing/CONTRIBUTING.md)
- [Inventory](./guides/inventory.md)
---
How to set up a development environment
Clan's declaration format for running **services** on one or multiple **machines**.
- [Flake-parts](./guides/flake-parts.md)
---
Use Clan with [https://flake.parts/]()
- [Contribute](./developer/contributing/CONTRIBUTING.md)
---
Discover how to set up a development environment to contribute to Clan!
- [macOS machines](./guides/macos.md)
---
Manage macOS machines with nix-darwin
</div>
## Concepts
## API Reference
Explore the underlying principles of Clan
**Reference API Documentation**
<div class="grid cards" markdown>
- [Generators](./concepts/generators.md)
- [CLI Reference](./reference/cli/index.md)
---
Learn about Generators, our way to secret management
The `clan` CLI command
- [Inventory](./concepts/inventory.md)
- [Service Modules](./reference/clanServices/index.md)
---
Learn about the Inventory, a multi machine Nix interface
An overview of available service modules
- [Core](./reference/clan.core/index.md)
---
The clan core nix module.
This is imported when using clan and is the basis of the extra functionality
that can be provided.
- [(Legacy) Modules](./reference/clanModules/index.md)
---
An overview of available clanModules
!!! Example "These will be deprecated soon"
</div>
## Blog
Visit our [Clan Blog](https://clan.lol/blog/) for the latest updates, tutorials, and community stories.

View File

@@ -4,10 +4,10 @@ This section of the site provides an overview of available options and commands
---
- [Clan Configuration Option](../options.md) - for defining a Clan
- Learn how to use the [Clan CLI](./cli/index.md)
- Explore available services and application [modules](./clanModules/index.md)
- [NixOS Configuration Options](./clan.core/index.md) - Additional options avilable on a NixOS machine.
- Discover [configuration options](./clan.core/index.md) that manage essential features
- Find descriptions of the [Nix interfaces](./nix-api/clan.md) for defining a Clan
---

6
flake.lock generated
View File

@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1753772294,
"narHash": "sha256-8rkd13WfClfZUBIYpX5dvG3O9V9w3K9FPQ9rY14VtBE=",
"lastModified": 1753006367,
"narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "6b9214fffbcf3f1e608efa15044431651635ca83",
"rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
"type": "github"
},
"original": {

View File

@@ -50,7 +50,6 @@
pathExists
;
# Load private flake inputs if available
loadDevFlake =
path:
let
@@ -61,13 +60,7 @@
devFlake = builtins.tryEval (loadDevFlake ./devFlake/private);
privateInputs =
if pathExists ./.skip-private-inputs then
{ }
else if devFlake.success then
devFlake.value.inputs
else
{ };
privateInputs = if devFlake.success then devFlake.value.inputs else { };
in
flake-parts.lib.mkFlake { inherit inputs; } (
{ ... }:

View File

@@ -142,7 +142,7 @@ in
- The module MUST have at least `features = [ "inventory" ]` in the frontmatter section.
- The module MUST have a subfolder `roles` with at least one `{roleName}.nix` file.
For further information see: [Module Authoring Guide](../../guides/services/community.md).
For further information see: [Module Authoring Guide](../../developer/extensions/clanServices/index.md).
???+ example
```nix
@@ -179,7 +179,8 @@ in
map (m: "'${m}'") (lib.attrNames (lib.filterAttrs (n: _v: !builtins.elem n allowedNames) moduleSet))
)}
See: https://docs.clan.lol/guides/services/community/
See: https://docs.clan.lol/developer/extensions/clanServices/
And: https://docs.clan.lol/developer/extensions/clanServices/
'' moduleSet;
};

View File

@@ -1,9 +1,4 @@
{
pkgs,
lib,
options,
...
}:
{ pkgs, lib, ... }:
{
boot.isContainer = true;
@@ -12,9 +7,7 @@
# undo qemu stuff
system.build.initialRamdisk = "";
virtualisation = lib.optionalAttrs (options ? virtualisation.sharedDirectories) {
sharedDirectories = lib.mkForce { };
};
virtualisation.sharedDirectories = lib.mkForce { };
networking.useDHCP = false;
# PAM requires setuid and doesn't work in our containers
@@ -22,14 +15,11 @@
# We use networkd to assign static ip addresses
networking.useNetworkd = true;
networking.useHostResolvConf = false;
services.resolved.enable = false;
# Rename the host0 interface to eth1 to match what we expect in VM tests.
# Rename the host0 interface to eth0 to match what we expect in VM tests.
system.activationScripts.renameInterface = ''
if ${pkgs.iproute2}/bin/ip link show host0 2>/dev/null; then
${pkgs.iproute2}/bin/ip link set dev host0 name eth1
fi
${pkgs.iproute2}/bin/ip link set dev host0 name eth1
'';
systemd.services.backdoor.enable = false;
@@ -37,12 +27,6 @@
# we don't have permission to set cpu scheduler in our container
systemd.services.nix-daemon.serviceConfig.CPUSchedulingPolicy = lib.mkForce "";
# Disable suid-sgid-wrappers.service as it fails in the nix sandbox
systemd.services.suid-sgid-wrappers.enable = false;
# Disable resolvconf as it can cause issues in containers because it cannot apply posix acl
systemd.services.resolvconf.enable = false;
# Adds `Include /nix/store/...` to `/etc/ssh/ssh_config`[1] which will make
# SSH fail when running inside a container test as SSH checks the permissions
# of the config files it reads which can't be disabled[2] and all the store

View File

@@ -13,80 +13,13 @@ from contextlib import _GeneratorContextManager
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from tempfile import TemporaryDirectory
from typing import Any
from colorama import Fore, Style
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
# Global flag to track if test environment has been initialized
_test_env_initialized = False
def init_test_environment() -> None:
"""Set up the test environment (network bridge, /etc/passwd) once."""
global _test_env_initialized
if _test_env_initialized:
return
# Set up network bridge
subprocess.run(
["ip", "link", "add", "br0", "type", "bridge"], check=True, text=True
)
subprocess.run(["ip", "link", "set", "br0", "up"], check=True, text=True)
subprocess.run(
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"], check=True, text=True
)
# Set up minimal passwd file for unprivileged operations
# Using Nix's convention: UID 1000 for nixbld user, GID 100 for nixbld group
passwd_content = """root:x:0:0:Root:/root:/bin/sh
nixbld:x:1000:100:Nix build user:/tmp:/bin/sh
nobody:x:65534:65534:Nobody:/:/bin/sh
"""
with NamedTemporaryFile(mode="w", delete=False, prefix="test-passwd-") as f:
f.write(passwd_content)
passwd_path = f.name
# Set up minimal group file
group_content = """root:x:0:
nixbld:x:100:nixbld
nogroup:x:65534:
"""
with NamedTemporaryFile(mode="w", delete=False, prefix="test-group-") as f:
f.write(group_content)
group_path = f.name
# Bind mount our passwd over the system's /etc/passwd
result = libc.mount(
ctypes.c_char_p(passwd_path.encode()),
ctypes.c_char_p(b"/etc/passwd"),
ctypes.c_char_p(b"none"),
ctypes.c_ulong(MS_BIND),
None,
)
if result != 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno), "Failed to mount passwd")
# Bind mount our group over the system's /etc/group
result = libc.mount(
ctypes.c_char_p(group_path.encode()),
ctypes.c_char_p(b"/etc/group"),
ctypes.c_char_p(b"none"),
ctypes.c_ulong(MS_BIND),
None,
)
if result != 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno), "Failed to mount group")
_test_env_initialized = True
# Load the C library
libc = ctypes.CDLL("libc.so.6", use_errno=True)
@@ -190,7 +123,6 @@ class Machine:
def start(self) -> None:
prepare_machine_root(self.name, self.rootdir)
init_test_environment()
cmd = [
"systemd-nspawn",
"--keep-unit",
@@ -214,7 +146,6 @@ class Machine:
def get_systemd_process(self) -> int:
assert self.process is not None, "Machine not started"
assert self.process.stdout is not None, "Machine has no stdout"
for line in self.process.stdout:
print(line, end="")
if (
@@ -488,7 +419,6 @@ def setup_filesystems(container: ContainerInfo) -> None:
Path("/etc/os-release").touch()
Path("/etc/machine-id").write_text("a5ea3f98dedc0278b6f3cc8c37eeaeac")
container.nix_store_dir.mkdir(parents=True)
container.nix_store_dir.chmod(0o755)
# Recreate symlinks
for file in Path("/nix/store").iterdir():
@@ -561,8 +491,12 @@ class Driver:
)
def start_all(self) -> None:
# Ensure test environment is set up
init_test_environment()
# child
# create bridge
subprocess.run(
["ip", "link", "add", "br0", "type", "bridge"], check=True, text=True
)
subprocess.run(["ip", "link", "set", "br0", "up"], check=True, text=True)
for machine in self.machines:
print(f"Starting {machine.name}")

View File

@@ -18,7 +18,6 @@
++ lib.optionals (_class == "nixos") [
./nixos-facter.nix
./vm.nix
./postgresql
./machine-id
./state-version
./wayland-proxy-virtwl.nix

View File

@@ -31,7 +31,6 @@
The deployment data is now accessed directly from the configuration
instead of being written to a separate JSON file.
'';
defaultText = "error: deployment.json file generation has been removed in favor of direct selectors.";
};
deployment.buildHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
@@ -55,10 +54,10 @@
deployment.nixosMobileWorkaround = lib.mkOption {
type = lib.types.bool;
description = ''
if true, the deployment will first do a nixos-rebuild switch
if true, the deployment will first do a nixos-rebuild switch
to register the boot profile the command will fail applying it to the running system
which is why afterwards we execute a nixos-rebuild test to apply
the new config without having to reboot.
which is why afterwards we execute a nixos-rebuild test to apply
the new config without having to reboot.
This is a nixos-mobile deployment bug and will be removed in the future
'';
default = false;

View File

@@ -1,236 +0,0 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.clan.core.postgresql;
createDatabaseState =
db:
let
folder = "/var/backup/postgres/${db.name}";
current = "${folder}/pg-dump";
compression = lib.optionalString (lib.versionAtLeast config.services.postgresql.package.version "16") "--compress=zstd";
in
{
folders = [ folder ];
preBackupScript = ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
mkdir -p "${folder}"
runuser -u postgres -- pg_dump ${compression} --dbname=${db.name} -Fc -c > "${current}.tmp"
mv "${current}.tmp" ${current}
'';
postRestoreScript = ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
pkgs.gnugrep
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
echo "Waiting for postgres to be ready..."
while ! runuser -u postgres -- psql --port=${builtins.toString config.services.postgresql.settings.port} -d postgres -c "" ; do
if ! systemctl is-active postgresql; then exit 1; fi
sleep 0.1
done
if [[ -e "${current}" ]]; then
(
systemctl stop ${lib.concatStringsSep " " db.restore.stopOnRestore}
trap "systemctl start ${lib.concatStringsSep " " db.restore.stopOnRestore}" EXIT
mkdir -p "${folder}"
if runuser -u postgres -- psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${db.name}'" | grep -q 1; then
runuser -u postgres -- dropdb "${db.name}"
fi
runuser -u postgres -- pg_restore -C -d postgres "${current}"
)
else
echo No database backup found, skipping restore
fi
'';
};
createDatabase = db: ''
CREATE DATABASE "${db.name}" ${
lib.concatStringsSep " " (
lib.mapAttrsToList (name: value: "${name} = '${value}'") db.create.options
)
}
'';
userClauses = lib.mapAttrsToList (
_: user:
''$PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname='${user.name}'" | grep -q 1 || $PSQL -tAc 'CREATE USER "${user.name}"' ''
) cfg.users;
databaseClauses = lib.mapAttrsToList (
name: db:
lib.optionalString db.create.enable ''$PSQL -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${name}'" | grep -q 1 || $PSQL -d postgres -c ${lib.escapeShellArg (createDatabase db)} ''
) cfg.databases;
in
{
options.clan.core.postgresql = {
enable = lib.mkEnableOption "Whether to enable PostgreSQL Server";
# we are reimplemeting ensureDatabase and ensureUser options here to allow to create databases with options
databases = lib.mkOption {
description = "Databases to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
description = "Database name.";
};
service = lib.mkOption {
type = lib.types.str;
default = name;
description = "Service name that we associate with the database.";
};
# set to false, in case the upstream module uses ensureDatabase option
create.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Create the database if it does not exist.";
};
create.options = lib.mkOption {
description = "Options to pass to the CREATE DATABASE command.";
type = lib.types.lazyAttrsOf lib.types.str;
default = { };
example = {
TEMPLATE = "template0";
LC_COLLATE = "C";
LC_CTYPE = "C";
ENCODING = "UTF8";
OWNER = "foo";
};
};
restore.stopOnRestore = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of systemd services to stop before restoring the database.";
};
};
}
)
);
};
users = lib.mkOption {
description = "Users to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options.name = lib.mkOption {
description = "User name";
type = lib.types.str;
default = name;
};
}
)
);
};
};
config = lib.mkIf (config.clan.core.postgresql.enable) {
clan.core.settings.state-version.enable = true;
# services.postgresql.package = lib.mkDefault pkgs.postgresql_16;
services.postgresql.enable = true;
services.postgresql.settings = {
wal_level = "replica";
max_wal_senders = 3;
};
# We are duplicating a bit the upstream module but allow to create databases with options
systemd.services.postgresql.postStart = ''
PSQL="psql --port=${builtins.toString config.services.postgresql.settings.port}"
while ! $PSQL -d postgres -c "" 2> /dev/null; do
if ! kill -0 "$MAINPID"; then exit 1; fi
sleep 0.1
done
${lib.concatStringsSep "\n" userClauses}
${lib.concatStringsSep "\n" databaseClauses}
'';
clan.core.state = lib.mapAttrs' (
_: db: lib.nameValuePair db.service (createDatabaseState db)
) config.clan.core.postgresql.databases;
environment.systemPackages = builtins.map (
db:
let
folder = "/var/backup/postgres/${db.name}";
current = "${folder}/pg-dump";
in
pkgs.writeShellScriptBin "postgres-db-restore-command-${db.name}" ''
export PATH=${
lib.makeBinPath [
config.services.postgresql.package
config.systemd.package
pkgs.coreutils
pkgs.util-linux
pkgs.zstd
pkgs.gnugrep
]
}
while [[ "$(systemctl is-active postgresql)" == activating ]]; do
sleep 1
done
echo "Waiting for postgres to be ready..."
while ! runuser -u postgres -- psql --port=${builtins.toString config.services.postgresql.settings.port} -d postgres -c "" ; do
if ! systemctl is-active postgresql; then exit 1; fi
sleep 0.1
done
if [[ -e "${current}" ]]; then
(
${lib.optionalString (db.restore.stopOnRestore != [ ]) ''
systemctl stop ${builtins.toString db.restore.stopOnRestore}
trap "systemctl start ${builtins.toString db.restore.stopOnRestore}" EXIT
''}
mkdir -p "${folder}"
if runuser -u postgres -- psql -d postgres -c "SELECT 1 FROM pg_database WHERE datname = '${db.name}'" | grep -q 1; then
runuser -u postgres -- dropdb "${db.name}"
fi
runuser -u postgres -- pg_restore -C -d postgres "${current}"
)
else
echo No database backup found, skipping restore
fi
''
) (builtins.attrValues config.clan.core.postgresql.databases);
};
}

View File

@@ -1,106 +0,0 @@
{ self, ... }:
{
perSystem =
{ ... }:
{
clan.nixosTests.postgresql = {
name = "service-postgresql";
clan = {
directory = ./.;
# Workaround until we can use nodes.machine = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.machine = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
imports = [
# self.nixosModules.clanCore
self.clanModules.localbackup
];
clan.core.postgresql.enable = true;
clan.core.postgresql.users.test = { };
clan.core.postgresql.databases.test.create.options.OWNER = "test";
clan.core.postgresql.databases.test.restore.stopOnRestore = [ "sample-service" ];
clan.localbackup.targets.hdd.directory = "/mnt/external-disk";
clan.core.settings.directory = ./.;
systemd.services.sample-service = {
wantedBy = [ "multi-user.target" ];
script = ''
while true; do
echo "Hello, world!"
sleep 5
done
'';
};
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.machine = { };
testScript =
{ nodes, ... }:
''
start_all()
machine.wait_for_unit("postgresql")
machine.wait_for_unit("sample-service")
# Create a test table
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -c 'CREATE TABLE test (id serial PRIMARY KEY);' test")
machine.succeed("/run/current-system/sw/bin/localbackup-create >&2")
timestamp_before = int(machine.succeed("systemctl show --property=ExecMainStartTimestampMonotonic sample-service | cut -d= -f2").strip())
# import time
# time.sleep(5400000)
machine.succeed("test -e /mnt/external-disk/snapshot.0/machine/var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'INSERT INTO test DEFAULT VALUES;'")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'DROP TABLE test;'")
machine.succeed("test -e /var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("rm -rf /var/backup/postgres")
machine.succeed("NAME=/mnt/external-disk/snapshot.0 FOLDERS=/var/backup/postgres/test /run/current-system/sw/bin/localbackup-restore >&2")
machine.succeed("test -e /var/backup/postgres/test/pg-dump || { echo 'pg-dump not found'; exit 1; }")
machine.succeed("""
set -x
${nodes.machine.clan.core.state.test.postRestoreCommand}
""")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -l >&2")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c '\dt' >&2")
timestamp_after = int(machine.succeed("systemctl show --property=ExecMainStartTimestampMonotonic sample-service | cut -d= -f2").strip())
assert timestamp_before < timestamp_after, f"{timestamp_before} >= {timestamp_after}: expected sample-service to be restarted after restore"
# Check that the table is still there
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c 'SELECT * FROM test;'")
output = machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql --csv -c \"SELECT datdba::regrole FROM pg_database WHERE datname = 'test'\"")
owner = output.split("\n")[1]
assert owner == "test", f"Expected database owner to be 'test', got '{owner}'"
# check if restore works if the database does not exist
machine.succeed("runuser -u postgres -- dropdb test")
machine.succeed("${nodes.machine.clan.core.state.test.postRestoreCommand}")
machine.succeed("runuser -u postgres -- /run/current-system/sw/bin/psql -d test -c '\dt' >&2")
'';
};
};
}

View File

@@ -123,8 +123,20 @@
@apply pr-3.5;
}
& > div.loader {
@apply w-0 opacity-0;
@apply top-0 left-0 -mr-2;
transition: all 0.5s ease;
}
&.loading {
@apply cursor-wait;
& > div.loader {
@apply w-4 opacity-100;
margin-right: revert;
transition: all 0.5s ease;
}
}
& > span.typography {

View File

@@ -67,11 +67,6 @@ export const Button = (props: ButtonProps) => {
const iconSize = iconSizes[local.size || "default"];
const loadingClass =
"w-4 opacity-100 mr-[revert] transition-all duration-500 ease-linear";
const idleClass =
"hidden w-0 opacity-0 top-0 left-0 -mr-2 transition-all duration-500 ease-linear";
return (
<KobalteButton
class={cx(
@@ -88,10 +83,7 @@ export const Button = (props: ButtonProps) => {
onClick={local.onAction ? onClick : undefined}
{...other}
>
<Loader
hierarchy={hierarchy}
class={cx({ [idleClass]: !loading(), [loadingClass]: loading() })}
/>
<Loader hierarchy={hierarchy} />
{local.startIcon && (
<Icon icon={local.startIcon} class="icon-start" size={iconSize} />

View File

@@ -1,10 +1,8 @@
import {
Checkbox as KCheckbox,
CheckboxInputProps as KCheckboxInputProps,
CheckboxRootProps as KCheckboxRootProps,
} from "@kobalte/core/checkbox";
import { Checkbox as KCheckbox } from "@kobalte/core";
import Icon from "@/src/components/Icon/Icon";
import cx from "classnames";
@@ -13,7 +11,7 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Match, splitProps, Switch } from "solid-js";
import { Show } from "solid-js";
export type CheckboxProps = FieldProps &
KCheckboxRootProps & {
@@ -21,9 +19,6 @@ export type CheckboxProps = FieldProps &
};
export const Checkbox = (props: CheckboxProps) => {
// we need to separate output the input otherwise it interferes with prop binding
const [_, rootProps] = splitProps(props, ["input"]);
const alignment = () =>
(props.orientation || "vertical") == "vertical" ? "start" : "center";
@@ -46,36 +41,34 @@ export const Checkbox = (props: CheckboxProps) => {
);
return (
<KCheckbox.Root
<KCheckbox
class={cx("form-field", "checkbox", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...rootProps}
{...props}
>
{(state) => (
<Orienter orientation={props.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
<Switch>
<Match when={!props.readOnly}>
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
</Match>
<Match when={props.readOnly && state.checked()}>
{iconChecked}
</Match>
<Match when={props.readOnly && !state.checked()}>
{iconUnchecked}
</Match>
</Switch>
</KCheckbox.Control>
</Orienter>
)}
</KCheckbox.Root>
<Orienter orientation={props.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
{props.readOnly && (
<Show
when={props.checked || props.defaultChecked}
fallback={iconUnchecked}
>
{iconChecked}
</Show>
)}
{!props.readOnly && (
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
)}
</KCheckbox.Control>
</Orienter>
</KCheckbox>
);
};

View File

@@ -12,20 +12,12 @@ import cx from "classnames";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { Typography } from "@/src/components/Typography/Typography";
import {
Accessor,
Component,
ComponentProps,
For,
Show,
splitProps,
} from "solid-js";
import { Accessor, Component, For, Show, splitProps } from "solid-js";
import { Tag } from "@/src/components/Tag/Tag";
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
KComboboxRootOptions<Option, OptGroup> & {
inverted: boolean;
input?: ComponentProps<"select">;
itemControl?: Component<ComboboxControlState<Option>>;
};
@@ -137,7 +129,6 @@ export const Combobox = <Option, OptGroup = never>(
{...props}
/>
<KCombobox.HiddenSelect {...props.input} />
<KCombobox.Control<Option> class="control">
{(state) => {
const [controlProps] = splitProps(props, [

View File

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

View File

@@ -1,7 +0,0 @@
.vertical_button {
@apply w-fit;
}
.horizontal_button {
@apply grow max-w-[18rem];
}

View File

@@ -7,7 +7,7 @@ import {
import cx from "classnames";
import { Label } from "./Label";
import { Button } from "../Button/Button";
import styles from "./HostFileInput.module.css";
import "./HostFileInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
@@ -41,7 +41,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
return (
<TextField
class={cx("form-field", props.size, props.orientation, {
class={cx("form-field", "host-file", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
@@ -73,11 +73,6 @@ export const HostFileInput = (props: HostFileInputProps) => {
startIcon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
class={cx(
props.orientation === "vertical"
? styles.vertical_button
: styles.horizontal_button,
)}
>
No Selection
</Button>

View File

@@ -1,221 +0,0 @@
div.form-field.machine-tags {
div.control {
@apply flex flex-col size-full gap-2;
div.selected-options {
@apply flex flex-wrap gap-2 size-full min-h-5;
}
div.input-container {
@apply relative left-0 top-0;
@apply inline-flex justify-between w-full;
input {
@apply w-full px-2 py-1.5 rounded-sm;
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
font-size: 0.875rem;
font-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 1;
&::placeholder {
@apply fg-def-4;
}
&:hover {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
0 0 0 0.125rem theme(colors.bg.def.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-disabled] {
@apply outline-def-2 fg-def-4 cursor-not-allowed;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit;
@apply p-0 resize-none;
}
}
& > button.trigger {
@apply flex items-center justify-center w-8;
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
&[data-disabled] {
@apply cursor-not-allowed;
}
& > span.icon {
@apply h-full w-full py-0.5 px-1;
}
}
}
}
&.horizontal {
@apply flex-row gap-2 justify-between;
div.control {
@apply w-1/2 grow;
}
}
&.s {
div.control > div.input-container {
& > input {
@apply px-1.5 py-1;
font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
}
& > button.trigger {
@apply top-[0.1875rem] h-4 w-5;
}
}
}
&.inverted {
div.control > div.input-container {
& > button.trigger {
@apply bg-inv-2;
}
& > input {
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
&::placeholder {
@apply fg-inv-4;
}
&:hover {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
}
&[data-invalid] {
@apply outline-semantic-error-4;
}
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
}
}
}
&.ghost {
div.control > div.input-container {
& > input {
@apply outline-none;
&:hover {
@apply outline-none;
}
}
}
}
}
div.machine-tags-content {
@apply rounded-sm bg-def-1 border border-def-2;
transform-origin: var(--kb-combobox-content-transform-origin);
animation: machineTagsContentHide 250ms ease-in forwards;
&[data-expanded] {
animation: machineTagsContentShow 250ms ease-out;
}
& > ul.listbox {
overflow-y: auto;
max-height: 360px;
@apply px-2 py-3;
&:focus {
outline: none;
}
li.item {
@apply flex items-center justify-between;
@apply relative px-2 py-1;
@apply select-none outline-none rounded-[0.25rem];
color: hsl(240 4% 16%);
height: 32px;
&[data-disabled] {
color: hsl(240 5% 65%);
opacity: 0.5;
pointer-events: none;
}
&[data-highlighted] {
@apply outline-none bg-def-4;
}
}
.item-indicator {
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
div.machine-tags-control {
@apply flex flex-col w-full gap-2;
& > div.selected-options {
@apply flex gap-2 flex-wrap w-full;
}
& > div.input-container {
@apply w-full flex gap-2;
}
}
@keyframes machineTagsContentShow {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes machineTagsContentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -1,192 +0,0 @@
import { Combobox } from "@kobalte/core/combobox";
import { FieldProps } from "./Field";
import { ComponentProps, createSignal, For, Show, splitProps } from "solid-js";
import Icon from "../Icon/Icon";
import cx from "classnames";
import { Typography } from "@/src/components/Typography/Typography";
import { Tag } from "@/src/components/Tag/Tag";
import "./MachineTags.css";
import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter";
import { CollectionNode } from "@kobalte/core";
interface MachineTag {
value: string;
disabled?: boolean;
}
export type MachineTagsProps = FieldProps & {
name: string;
input: ComponentProps<"select">;
readOnly?: boolean;
disabled?: boolean;
required?: boolean;
defaultValue?: string[];
};
// tags which are applied to all machines and cannot be removed
const staticOptions = [{ value: "all", disabled: true }];
const uniqueOptions = (options: MachineTag[]) => {
const record: Record<string, MachineTag> = {};
options.forEach((option) => {
// we want to preserve the first one we encounter
// this allows us to prefix the default 'all' tag
record[option.value] = record[option.value] || option;
});
return Object.values(record);
};
const sortedOptions = (options: MachineTag[]) => {
return options.sort((a, b) => {
if (a.disabled && !b.disabled) return -1;
return a.value.localeCompare(b.value);
});
};
const sortedAndUniqueOptions = (options: MachineTag[]) =>
sortedOptions(uniqueOptions(options));
// customises how each option is displayed in the dropdown
const ItemComponent = (props: { item: CollectionNode<MachineTag> }) => {
return (
<Combobox.Item item={props.item} class="item">
<Combobox.ItemLabel>
<Typography hierarchy="body" size="xs" weight="bold">
{props.item.textValue}
</Typography>
</Combobox.ItemLabel>
<Combobox.ItemIndicator class="item-indicator">
<Icon icon="Checkmark" />
</Combobox.ItemIndicator>
</Combobox.Item>
);
};
export const MachineTags = (props: MachineTagsProps) => {
// convert default value string[] into MachineTag[]
const defaultValue = (props.defaultValue || []).map((value) => ({ value }));
// controlled values for selected and available options
const [selectedOptions, setSelectedOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([...staticOptions, ...defaultValue]),
);
// todo this should be the superset of tags used across the entire clan and be passed in via a prop
const [availableOptions, setAvailableOptions] = createSignal<MachineTag[]>(
sortedAndUniqueOptions([...staticOptions, ...defaultValue]),
);
const addOptionAndSelect = (option: MachineTag) => {
// add to the list of available options first
setAvailableOptions(
sortedAndUniqueOptions([...availableOptions(), option]),
);
// update the selected options
setSelectedOptions(sortedAndUniqueOptions([...selectedOptions(), option]));
};
const removeSelectedOption = (option: MachineTag) => {
setSelectedOptions(
selectedOptions().filter((o) => o.value !== option.value),
);
};
const onKeyDown = (event: KeyboardEvent) => {
// react when enter is pressed inside of the text input
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
// get the current input value, exiting early if it's empty
const input = event.currentTarget as HTMLInputElement;
if (input.value === "") return;
// add the input value to the selected options
addOptionAndSelect({ value: input.value });
// reset the input value
input.value = "";
}
};
const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return (
<Combobox<MachineTag>
multiple
class={cx("form-field", "machine-tags", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...splitProps(props, ["defaultValue"])[1]}
value={selectedOptions()}
options={availableOptions()}
optionValue="value"
optionTextValue="value"
optionLabel="value"
optionDisabled="disabled"
itemComponent={ItemComponent}
placeholder="Enter a tag name"
>
<Orienter orientation={props.orientation} align={align()}>
<Label
labelComponent={Combobox.Label}
descriptionComponent={Combobox.Description}
{...props}
/>
<Combobox.HiddenSelect {...props.input} multiple />
<Combobox.Control<MachineTag> class="control">
<div class="selected-options">
<For each={selectedOptions()}>
{(option) => (
<Tag
label={option.value}
inverted={props.inverted}
action={
option.disabled || props.disabled || props.readOnly
? undefined
: {
icon: "Close",
onClick: () => removeSelectedOption(option),
}
}
/>
)}
</For>
<Show when={!props.readOnly}>
<div class="input-container">
<Combobox.Input onKeyDown={onKeyDown} />
<Combobox.Trigger class="trigger">
<Combobox.Icon class="icon">
<Icon
icon="Expand"
inverted={!props.inverted}
size="100%"
/>
</Combobox.Icon>
</Combobox.Trigger>
</div>
</Show>
</div>
</Combobox.Control>
</Orienter>
<Combobox.Portal>
<Combobox.Content class="machine-tags-content">
<Combobox.Listbox class="listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
};

View File

@@ -18,35 +18,33 @@ export type TextInputProps = FieldProps &
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
};
export const TextInput = (props: TextInputProps) => {
return (
<TextField
class={cx("form-field", "text", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
>
<Orienter orientation={props.orientation}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<div class="input-container">
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
inverted={props.inverted}
color={props.disabled ? "tertiary" : "quaternary"}
/>
)}
<TextField.Input
{...props.input}
classList={{ "has-icon": props.icon && !props.readOnly }}
export const TextInput = (props: TextInputProps) => (
<TextField
class={cx("form-field", "text", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
>
<Orienter orientation={props.orientation}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<div class="input-container">
{props.icon && !props.readOnly && (
<Icon
icon={props.icon}
inverted={props.inverted}
color={props.disabled ? "tertiary" : "quaternary"}
/>
</div>
</Orienter>
</TextField>
);
};
)}
<TextField.Input
{...props.input}
classList={{ "has-icon": props.icon && !props.readOnly }}
/>
</div>
</Orienter>
</TextField>
);

View File

@@ -15,30 +15,30 @@
background: #0051ff;
}
}
}
.wrapper {
@apply absolute top-0 left-0 w-full h-full;
& > div.wrapper {
@apply absolute top-0 left-0 w-full h-full;
transform: translate(0%, 0%) rotate(-45deg);
animation: moveLoaderWrapper 1.8s ease-in-out infinite;
}
transform: translate(0%, 0%) rotate(-45deg);
animation: moveLoaderWrapper 1.8s ease-in-out infinite;
.parent {
@apply absolute top-1/2 left-1/2;
@apply w-2/3 h-2/3;
& > div.parent {
@apply absolute top-1/2 left-1/2;
@apply w-2/3 h-2/3;
border-radius: 50%;
animation: moveLoaderParent 1.8s ease-in-out infinite;
transform: translateX(-50%) translateY(-50%);
}
border-radius: 50%;
animation: moveLoaderParent 1.8s ease-in-out infinite;
transform: translateX(-50%) translateY(-50%);
}
}
.child {
@apply absolute z-10 top-1/2 left-1/2 w-1/2 h-1/2;
border-radius: 50%;
& > .child {
@apply absolute z-10 top-1/2 left-1/2 w-1/2 h-1/2;
border-radius: 50%;
transform: translate(-50%, -50%);
animation: moveLoaderChild 1.8s ease-in-out infinite;
transform: translate(-50%, -50%);
animation: moveLoaderChild 1.8s ease-in-out infinite;
}
}
@keyframes moveLoaderWrapper {

View File

@@ -1,27 +1,19 @@
// Loader.tsx
import styles from "./Loader.module.css";
import "./Loader.css";
import cx from "classnames";
export type Hierarchy = "primary" | "secondary";
export interface LoaderProps {
hierarchy?: Hierarchy;
class?: string;
}
export const Loader = (props: LoaderProps) => {
return (
<div
class={cx(
styles.loader,
styles[props.hierarchy || "primary"],
props.class,
)}
>
<div class={styles.wrapper}>
<div class={styles.parent}></div>
<div class={cx("loader", props.hierarchy || "primary")}>
<div class="wrapper">
<div class="parent"></div>
</div>
<div class={styles.child}></div>
<div class="child"></div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
div.modal-content {
@apply min-w-[320px] max-w-[512px];
@apply rounded-md;
/* todo replace with a theme() color */
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
& > div.header {
@apply flex items-center justify-center;
@apply w-full px-2 py-1.5;
@apply bg-def-3;
@apply border border-def-2 rounded-tl-md rounded-tr-md;
@apply border-b-def-3;
& > .modal-title {
@apply mx-auto;
}
}
& > div.body {
@apply p-6 bg-def-1;
@apply border border-def-2 rounded-bl-md rounded-br-md;
}
}

View File

@@ -1,24 +0,0 @@
.modal_content {
@apply min-w-[320px] max-w-[512px];
@apply rounded-md;
/* todo replace with a theme() color */
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
}
.modal_header {
@apply flex items-center justify-center;
@apply w-full px-2 py-1.5;
@apply bg-def-3;
@apply border border-def-2 rounded-tl-md rounded-tr-md;
@apply border-b-def-3;
}
.modal_title {
@apply mx-auto;
}
.modal_body {
@apply p-6 bg-def-1;
@apply border border-def-2 rounded-bl-md rounded-br-md;
}

View File

@@ -1,6 +1,6 @@
import { createSignal, JSX } from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog";
import styles from "./Modal.module.css";
import "./Modal.css";
import { Typography } from "../Typography/Typography";
import Icon from "../Icon/Icon";
import cx from "classnames";
@@ -24,10 +24,10 @@ export const Modal = (props: ModalProps) => {
return (
<KDialog id={props.id} open={open()} modal={true}>
<KDialog.Portal mount={props.mount}>
<KDialog.Content class={cx(styles.modal_content, props.class)}>
<div class={styles.modal_header}>
<KDialog.Content class={cx("modal-content", props.class)}>
<div class="header">
<Typography
class={styles.modal_title}
class="modal-title"
hierarchy="label"
family="mono"
size="xs"
@@ -43,7 +43,7 @@ export const Modal = (props: ModalProps) => {
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<div class={styles.modal_body}>
<div class="body">
{props.children({
close: () => {
setOpen(false);

View File

@@ -1,5 +1,5 @@
div.sidebar {
@apply w-60 border-none z-10;
@apply w-60 border-none;
& > div.header {
}

View File

@@ -6,7 +6,7 @@ import { Typography } from "@/src/components/Typography/Typography";
import { For } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useMachinesQuery } from "@/src/queries/queries";
import { SidebarProps } from "./Sidebar";
interface MachineProps {

View File

@@ -39,7 +39,7 @@ div.sidebar-header {
}
.sidebar-dropdown-content {
@apply flex flex-col w-full px-2 py-1.5 z-10;
@apply flex flex-col w-full px-2 py-1.5;
@apply bg-def-1 rounded-bl-md rounded-br-md;
@apply border border-def-2;

View File

@@ -4,7 +4,7 @@ import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography";
import { createSignal, For, Suspense } from "solid-js";
import { useClanListQuery } from "@/src/hooks/queries";
import { useClanListQuery } from "@/src/queries/queries";
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
import { clanURIs } from "@/src/stores/clan";

View File

@@ -1,20 +1,5 @@
div.sidebar-pane {
@apply border-none z-10;
animation: sidebarPaneShow 250ms ease-in forwards;
&.open {
@apply w-60;
}
&.closing {
animation: sidebarPaneHide 250ms ease-out 300ms forwards;
& > div.header > *,
& > div.body > * {
animation: sidebarFadeOut 250ms ease-out forwards;
}
}
@apply w-full max-w-60 border-none;
& > div.header {
@apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem];
@@ -22,17 +7,11 @@ div.sidebar-pane {
border-r-[1px] border-r-bg-inv-3
border-b-2 border-b-bg-inv-4
border-l-[1px] border-l-bg-inv-3;
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
& > * {
@apply opacity-0;
animation: sidebarFadeIn 250ms ease-in 250ms forwards;
}
}
& > div.body {
@@ -50,54 +29,5 @@ div.sidebar-pane {
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
& > * {
@apply opacity-0;
animation: sidebarFadeIn 250ms ease-in 350ms forwards;
}
}
}
@keyframes sidebarPaneShow {
0% {
@apply w-0;
@apply opacity-0;
}
10% {
@apply w-8;
}
30% {
@apply opacity-100;
}
100% {
@apply w-60;
}
}
@keyframes sidebarPaneHide {
90% {
@apply w-8;
}
100% {
@apply w-0;
@apply opacity-0;
}
}
@keyframes sidebarFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes sidebarFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -8,23 +8,7 @@ import { Divider } from "@/src/components/Divider/Divider";
import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
import { Checkbox } from "@/src/components/Form/Checkbox";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import * as v from "valibot";
import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags";
type Story = StoryObj<SidebarPaneProps>;
const profiles = {
ron: {
firstName: "Ron",
lastName: "Burgundy",
bio: "It's actually an optical illusion, it's the pattern on the pants.",
shareProfile: true,
tags: ["all", "home Server", "backup", "random"],
},
};
import { Combobox } from "../Form/Combobox";
const meta: Meta<SidebarPaneProps> = {
title: "Components/SidebarPane",
@@ -33,6 +17,8 @@ const meta: Meta<SidebarPaneProps> = {
export default meta;
type Story = StoryObj<SidebarPaneProps>;
export const Default: Story = {
args: {
title: "Neptune",
@@ -41,126 +27,87 @@ export const Default: Story = {
},
children: (
<>
<SidebarSectionForm
<SidebarSection
title="General"
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
onSave={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="firstName">
{(field, input) => (
<TextInput
{...field}
size="s"
inverted
label="First Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
)}
</Field>
{(editing) => (
<form class="flex flex-col gap-3">
<TextInput
label="First Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Ron" }}
/>
<Divider />
<Field name="lastName">
{(field, input) => (
<TextInput
{...field}
size="s"
inverted
label="Last Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
)}
</Field>
<TextInput
label="Last Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Burgundy" }}
/>
<Divider />
<Field name="bio">
{(field, input) => (
<TextArea
{...field}
value={field.value}
size="s"
label="Bio"
inverted
readOnly={!editing}
orientation="horizontal"
input={{ ...input, rows: 4 }}
/>
)}
</Field>
<Field name="shareProfile" type="boolean">
{(field, input) => {
return (
<Checkbox
{...splitProps(field, ["value"])[1]}
defaultChecked={field.value}
size="s"
label="Share"
inverted
readOnly={!editing}
orientation="horizontal"
input={input}
/>
);
<TextArea
label="Bio"
size="s"
inverted={true}
readOnly={!editing}
orientation="horizontal"
input={{
value:
"It's actually an optical illusion, it's the pattern on the pants.",
rows: 4,
}}
</Field>
</div>
/>
<Divider />
<Checkbox
size="s"
label="Share Profile"
required={true}
inverted={true}
readOnly={!editing}
checked={true}
orientation="horizontal"
/>
</form>
)}
</SidebarSectionForm>
<SidebarSectionForm
</SidebarSection>
<SidebarSection
title="Tags"
schema={v.object({
tags: v.pipe(v.array(v.string()), v.nonEmpty()),
})}
initialValues={profiles.ron}
onSubmit={async (values) => {
console.log("saving tags", values);
onSave={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</Field>
{(editing) => (
<form class="flex flex-col gap-3">
<Combobox
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
multiple={true}
options={["All", "Home Server", "Backup", "Random"]}
defaultValue={["All", "Home Server", "Backup", "Random"]}
/>
</form>
)}
</SidebarSectionForm>
<SidebarSection title="Simple" class="flex flex-col">
<Typography tag="h2" hierarchy="title" size="m" inverted>
Static Content
</Typography>
<Typography hierarchy="label" size="s" inverted>
This is a non-form section with static content
</Typography>
</SidebarSection>
<SidebarSection
title="Advanced Settings"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => <></>}
</SidebarSection>
</>
),

View File

@@ -1,9 +1,8 @@
import { createSignal, JSX, onMount } from "solid-js";
import { JSX } from "solid-js";
import "./SidebarPane.css";
import { Typography } from "@/src/components/Typography/Typography";
import Icon from "../Icon/Icon";
import { Button as KButton } from "@kobalte/core/button";
import cx from "classnames";
export interface SidebarPaneProps {
title: string;
@@ -12,26 +11,13 @@ export interface SidebarPaneProps {
}
export const SidebarPane = (props: SidebarPaneProps) => {
const [closing, setClosing] = createSignal(false);
const [open, setOpened] = createSignal(true);
const onClose = () => {
setClosing(true);
setTimeout(() => props.onClose(), 550);
};
onMount(() => {
setTimeout(() => {
setOpened(true);
}, 250);
});
return (
<div class={cx("sidebar-pane", { closing: closing(), open: open() })}>
<div class="sidebar-pane">
<div class="header">
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
{props.title}
</Typography>
<KButton onClick={onClose}>
<KButton onClick={props.onClose}>
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
</KButton>
</div>

View File

@@ -1,5 +1,5 @@
div.sidebar-section {
@apply flex flex-col gap-2 w-full h-fit;
@apply flex flex-col gap-2 w-full h-full;
& > div.header {
@apply flex items-center justify-between px-1.5;
@@ -10,6 +10,10 @@ div.sidebar-section {
}
& > div.content {
@apply w-full h-full px-1.5 py-3 rounded-md bg-inv-4;
@apply w-full h-full px-1.5 py-3 rounded-md bg-inv-4 opacity-60;
}
&.editing > div.content {
@apply opacity-100;
}
}

View File

@@ -1,17 +1,27 @@
import { JSX } from "solid-js";
import { createSignal, JSX } from "solid-js";
import "./SidebarSection.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button as KButton } from "@kobalte/core/button";
import Icon from "../Icon/Icon";
import cx from "classnames";
export interface SidebarSectionProps {
title: string;
class?: string;
children: JSX.Element;
onSave: () => Promise<void>;
children: (editing: boolean) => JSX.Element;
}
export const SidebarSection = (props: SidebarSectionProps) => {
const [editing, setEditing] = createSignal(false);
const save = async () => {
// todo how do we surface errors?
await props.onSave();
setEditing(false);
};
return (
<div class={cx("sidebar-section", props.class)}>
<div class={cx("sidebar-section", { editing: editing() })}>
<div class="header">
<Typography
hierarchy="label"
@@ -24,8 +34,29 @@ export const SidebarSection = (props: SidebarSectionProps) => {
>
{props.title}
</Typography>
<div class="controls">
{editing() && (
<KButton>
<Icon
icon="Checkmark"
color="tertiary"
size="0.75rem"
inverted={true}
onClick={save}
/>
</KButton>
)}
<KButton onClick={() => setEditing(!editing())}>
<Icon
icon={editing() ? "Close" : "Edit"}
color="tertiary"
size="0.75rem"
inverted={true}
/>
</KButton>
</div>
</div>
<div class="content">{props.children}</div>
<div class="content">{props.children(editing())}</div>
</div>
);
};

View File

@@ -1,119 +0,0 @@
import { createSignal, JSX, Show } from "solid-js";
import {
createForm,
FieldValues,
getErrors,
Maybe,
PartialValues,
reset,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import { OperationNames, SuccessData } from "@/src/hooks/api";
import { GenericSchema, GenericSchemaAsync } from "valibot";
import { Typography } from "@/src/components/Typography/Typography";
import { Button as KButton } from "@kobalte/core/button";
import Icon from "@/src/components/Icon/Icon";
import "./SidebarSection.css";
import { Loader } from "../../components/Loader/Loader";
export interface SidebarSectionFormProps<FormValues extends FieldValues> {
title: string;
schema: GenericSchema<FormValues> | GenericSchemaAsync<FormValues>;
initialValues: PartialValues<FormValues>;
onSubmit: (values: FormValues) => Promise<void>;
children: (ctx: {
editing: boolean;
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
}) => JSX.Element;
}
export function SidebarSectionForm<
T extends OperationNames,
FormValues extends FieldValues = SuccessData<T> extends FieldValues
? SuccessData<T>
: never,
>(props: SidebarSectionFormProps<FormValues>) {
const [editing, setEditing] = createSignal(false);
const [formStore, { Form, Field }] = createForm<FormValues>({
initialValues: props.initialValues,
validate: valiForm<FormValues>(props.schema),
});
const editOrClose = () => {
if (editing()) {
reset(formStore, props.initialValues);
setEditing(false);
} else {
setEditing(true);
}
};
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
await props.onSubmit(values);
setEditing(false);
};
const errorMessage = (): Maybe<string> => {
const formErrors = getErrors(formStore);
const firstFormError = Object.values(formErrors).find(
(value) => value,
) as Maybe<string>;
return firstFormError || formStore.response.message;
};
return (
<Form onSubmit={handleSubmit}>
<div class="sidebar-section">
<div class="header">
<Typography
hierarchy="label"
size="xs"
family="mono"
weight="light"
transform="uppercase"
color="tertiary"
inverted
>
{props.title}
</Typography>
<div class="controls h-4">
{editing() && !formStore.submitting && (
<KButton type="submit">
<Icon
icon="Checkmark"
color="tertiary"
size="0.75rem"
inverted
/>
</KButton>
)}
{editing() && formStore.submitting && <Loader />}
<KButton onClick={editOrClose}>
<Icon
icon={editing() ? "Close" : "Edit"}
color="tertiary"
size="0.75rem"
inverted
/>
</KButton>
</div>
</div>
<div class="content">
<Show when={editing() && formStore.dirty && errorMessage()}>
<div class="mb-2.5" role="alert" aria-live="assertive">
<Typography hierarchy="body" size="xs" inverted color="error">
{errorMessage()}
</Typography>
</div>
</Show>
{props.children({ editing: editing(), Field })}
</div>
</div>
</Form>
);
}

View File

@@ -1,4 +1,4 @@
.toolbar {
div.toolbar {
@apply flex flex-row items-center justify-center gap-1.5 p-1 size-fit rounded-md self-stretch;
border: 0.0625rem solid theme(colors.off.toolbar_border);
@@ -11,20 +11,20 @@
& > hr {
@apply h-full min-h-8;
}
}
.toolbar_button {
@apply w-6 h-6 p-1 rounded-[0.25rem];
& > button.toolbar-button {
@apply w-6 h-6 p-1 rounded-[0.25rem];
&:hover {
@apply bg-inv-1;
}
&:hover {
@apply bg-inv-1;
}
&:active {
@apply bg-inv-4;
}
&:active {
@apply bg-inv-4;
}
&.selected {
@apply bg-bg-semantic-success-4;
&.selected {
@apply bg-bg-semantic-success-4;
}
}
}

View File

@@ -2,8 +2,6 @@ import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar";
import { Divider } from "@/src/components/Divider/Divider";
import { ToolbarButton } from "./ToolbarButton";
import { Tooltip } from "../Tooltip/Tooltip";
import { Typography } from "../Typography/Typography";
const meta: Meta<ToolbarProps> = {
title: "Components/Toolbar",
@@ -27,63 +25,3 @@ export const Default: Story = {
),
},
};
export const WithTooltip: Story = {
// @ts-expect-error: args in storybook is not typed correctly. This is a storybook issue.
render: (args) => (
<div class="flex h-[80vh]">
<div class="mt-auto">
<Toolbar {...args} />
</div>
</div>
),
args: {
children: (
<>
<Tooltip
trigger={<ToolbarButton name="select" icon="Cursor" />}
placement="top"
>
<div class="mb-1 p-1 text-fg-inv-1">
<Typography hierarchy="label" size="s" color="inherit">
Select an object
</Typography>
</div>
</Tooltip>
<Divider orientation="vertical" />
<Tooltip
trigger={<ToolbarButton name="new-machine" icon="NewMachine" />}
placement="top"
>
<div class="mb-1 p-1 text-fg-inv-1">
<Typography hierarchy="label" size="s" color="inherit">
Create a new machine
</Typography>
</div>
</Tooltip>
<Tooltip
trigger={
<ToolbarButton name="modules" icon="Modules" selected={true} />
}
placement="top"
>
<div class="mb-1 p-1 text-fg-inv-1">
<Typography hierarchy="label" size="s" color="inherit">
Manage Services
</Typography>
</div>
</Tooltip>
<Tooltip
trigger={<ToolbarButton name="ai" icon="AI" />}
placement="top"
>
<div class="mb-1 p-1 text-fg-inv-1">
<Typography hierarchy="label" size="s" color="inherit">
Chat with AI
</Typography>
</div>
</Tooltip>
</>
),
},
};

View File

@@ -1,5 +1,5 @@
import "./Toolbar.css";
import { JSX } from "solid-js";
import styles from "./Toolbar.module.css";
export interface ToolbarProps {
children: JSX.Element;
@@ -7,7 +7,7 @@ export interface ToolbarProps {
export const Toolbar = (props: ToolbarProps) => {
return (
<div class={styles.toolbar} role="toolbar" aria-orientation="horizontal">
<div class="toolbar" role="toolbar" aria-orientation="horizontal">
{props.children}
</div>
);

View File

@@ -1,4 +1,4 @@
import styles from "./Toolbar.module.css";
import "./Toolbar.css";
import cx from "classnames";
import { Button } from "@kobalte/core/button";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
@@ -13,9 +13,7 @@ export interface ToolbarButtonProps
export const ToolbarButton = (props: ToolbarButtonProps) => {
return (
<Button
class={cx(styles.toolbar_button, {
[styles["selected"]]: props.selected,
})}
class={cx("toolbar-button", { selected: props.selected })}
{...props}
>
<Icon icon={props.icon} inverted={!props.selected} />

View File

@@ -29,7 +29,7 @@ const invertedColorMap: Record<Color, string> = {
secondary: "fg-inv-2",
tertiary: "fg-inv-3",
quaternary: "fg-inv-4",
error: "fg-semantic-error-1",
error: "fg-semantic-error-2",
inherit: "text-inherit",
};

View File

@@ -1,7 +1,7 @@
import { API } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
export type OperationNames = keyof API;
type OperationNames = keyof API;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;

View File

@@ -27,8 +27,11 @@ export const buildClanPath = (clanURI: string) => {
return "/clans/" + encodeBase64(clanURI);
};
export const buildMachinePath = (clanURI: string, name: string) =>
buildClanPath(clanURI) + "/machines/" + name;
export const buildMachinePath = (clanURI: string, machineID: string) => {
return (
"/clans/" + encodeBase64(clanURI) + "/machines/" + encodeBase64(machineID)
);
};
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
const path = buildClanPath(clanURI);
@@ -39,10 +42,10 @@ export const navigateToClan = (navigate: Navigator, clanURI: string) => {
export const navigateToMachine = (
navigate: Navigator,
clanURI: string,
name: string,
machineID: string,
) => {
const path = buildMachinePath(clanURI, name);
console.log("Navigating to machine", clanURI, name, path);
const path = buildMachinePath(clanURI, machineID);
console.log("Navigating to machine", clanURI, machineID, path);
navigate(path);
};
@@ -52,16 +55,19 @@ export const clanURIParam = (params: Params) => {
export const useClanURI = () => clanURIParam(useParams());
export const machineNameParam = (params: Params) => {
return params.machineName;
export const machineIDParam = (params: Params) => {
return decodeBase64(params.machineID);
};
export const useMachineName = (): string => machineNameParam(useParams());
export const maybeUseMachineName = (): string | null => {
export const useMachineID = (): string => {
const params = useParams();
if (params.machineName === undefined) {
return machineIDParam(params);
};
export const maybeUseMachineID = (): string | null => {
const params = useParams();
if (params.machineID === undefined) {
return null;
}
return machineNameParam(params);
return machineIDParam(params);
};

View File

@@ -1,25 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query";
import { callApi, OperationArgs } from "@/src/hooks/api";
import { encodeBase64 } from "@/src/hooks/clan";
const queryClient = useQueryClient();
export const updateMachine = useMutation(() => ({
mutationFn: async (args: OperationArgs<"set_machine">) => {
const call = callApi("set_machine", args);
return {
args,
...call,
};
},
onSuccess: async ({ args }) => {
const {
name,
flake: { identifier },
} = args.machine;
await queryClient.invalidateQueries({
queryKey: ["clans", encodeBase64(identifier), "machine", name],
});
},
}));

View File

@@ -5,9 +5,7 @@ import { encodeBase64 } from "@/src/hooks/clan";
export type ClanDetails = SuccessData<"get_clan_details">;
export type ClanDetailsWithURI = ClanDetails & { uri: string };
export type Machine = SuccessData<"get_machine">;
export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">;
export type MachinesQueryResult = UseQueryResult<ListMachines>;
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
@@ -30,50 +28,6 @@ export const useMachinesQuery = (clanURI: string) =>
},
}));
export const useMachineQuery = (clanURI: string, machineName: string) =>
useQuery<Machine>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => {
const call = callApi("get_machine", {
name: machineName,
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error("Error fetching machine: " + result.errors[0].message);
}
return result.data;
},
}));
export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
useQuery<MachineDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
queryFn: async () => {
const call = callApi("get_machine_details", {
machine: {
name: machineName,
flake: {
identifier: clanURI,
},
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(
"Error fetching machine details: " + result.errors[0].message,
);
}
return result.data;
},
}));
export const useClanDetailsQuery = (clanURI: string) =>
useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"],

View File

@@ -11,7 +11,7 @@ import {
} from "solid-js";
import {
buildMachinePath,
maybeUseMachineName,
maybeUseMachineID,
useClanURI,
} from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
@@ -20,7 +20,7 @@ import {
MachinesQueryResult,
useClanListQuery,
useMachinesQuery,
} from "@/src/hooks/queries";
} from "@/src/queries/queries";
import { callApi } from "@/src/hooks/api";
import { store, setStore, clanURIs } from "@/src/stores/clan";
import { produce } from "solid-js/store";
@@ -79,7 +79,7 @@ const MockCreateMachine = (props: MockProps) => {
label="Name"
size="s"
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
input={{ ...props, placeholder: "name" }}
/>
</>
)}
@@ -162,7 +162,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
}
};
const machine = createMemo(() => maybeUseMachineName());
const machine = createMemo(() => maybeUseMachineID());
createEffect(
on(machine, (machineId) => {
@@ -220,6 +220,36 @@ const ClanSceneController = (props: RouteSectionProps) => {
}}
/>
</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] = {};
}
}),
);
}}
>
Reset Store
</Button>
<Button
ghost
onClick={() => {
console.log("Refetching API");
machinesQuery.refetch();
}}
>
Refetch API
</Button>
</div>
{/* TODO: Add minimal display time */}
<div
class={cx({
"fade-out": !machinesQuery.isLoading && loadingCooldown(),

View File

@@ -1,10 +1,6 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
import { Show } from "solid-js";
import { SectionGeneral } from "./SectionGeneral";
import { useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags";
import { navigateToClan, useClanURI, useMachineID } from "@/src/hooks/clan";
export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate();
@@ -15,21 +11,9 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI);
};
const sidebarPane = (machineName: string) => {
const machineQuery = useMachineQuery(clanURI, machineName);
const sectionProps = { clanURI, machineName, machineQuery };
return (
<SidebarPane title={machineName} onClose={onClose}>
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
);
};
return (
<Show when={useMachineName()} keyed>
{sidebarPane(useMachineName())}
</Show>
<SidebarPane title={useMachineID()} onClose={onClose}>
<h1>Hello world</h1>
</SidebarPane>
);
};

View File

@@ -1,125 +0,0 @@
import * as v from "valibot";
import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
import { Show, splitProps } from "solid-js";
import { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
const schema = v.object({
name: v.pipe(v.optional(v.string()), v.readonly()),
description: v.nullish(v.string()),
machineClass: v.optional(v.picklist(["nixos", "darwin"])),
});
type FormValues = v.InferInput<typeof schema>;
export interface SectionGeneralProps {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
}
export const SectionGeneral = (props: SectionGeneralProps) => {
const machineQuery = props.machineQuery;
const initialValues = () => {
if (!machineQuery.isSuccess) {
return {};
}
return pick(machineQuery.data, [
"name",
"description",
"machineClass",
]) satisfies FormValues;
};
const onSubmit = async (values: FormValues) => {
const call = callApi("set_machine", {
machine: {
name: props.machineName,
flake: {
identifier: props.clanURI,
},
},
update: {
...machineQuery.data,
...values,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(result.errors[0].message);
}
// refresh the query
await machineQuery.refetch();
};
return (
<Show when={machineQuery.isSuccess}>
<SidebarSectionForm
title="General"
schema={schema}
onSubmit={onSubmit}
initialValues={initialValues()}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="name">
{(field, input) => (
<TextInput
{...field}
value={field.value}
size="s"
inverted
label="Name"
required
readOnly
orientation="horizontal"
input={input}
/>
)}
</Field>
<Divider />
<Field name="machineClass">
{(field, input) => (
<TextInput
{...field}
value={field.value}
size="s"
inverted
label="Class"
required
readOnly
orientation="horizontal"
input={input}
/>
)}
</Field>
<Divider />
<Field name="description">
{(field, input) => (
<TextArea
{...splitProps(field, ["value"])[1]}
defaultValue={field.value ?? ""}
size="s"
label="Description"
inverted
readOnly={!editing}
orientation="horizontal"
input={{ ...input, rows: 4, placeholder: "No description" }}
/>
)}
</Field>
</div>
)}
</SidebarSectionForm>
</Show>
);
};

View File

@@ -1,86 +0,0 @@
import * as v from "valibot";
import { Show, splitProps } from "solid-js";
import { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
import { MachineTags } from "@/src/components/Form/MachineTags";
const schema = v.object({
tags: v.pipe(v.optional(v.array(v.string()))),
});
type FormValues = v.InferInput<typeof schema>;
export interface SectionTags {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
}
export const SectionTags = (props: SectionTags) => {
const machineQuery = props.machineQuery;
const initialValues = () => {
if (!machineQuery.isSuccess) {
return {};
}
return pick(machineQuery.data, ["tags"]) satisfies FormValues;
};
const onSubmit = async (values: FormValues) => {
console.log("submitting tags", values);
const call = callApi("set_machine", {
machine: {
name: props.machineName,
flake: {
identifier: props.clanURI,
},
},
update: {
...machineQuery.data,
...values,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(result.errors[0].message);
}
// refresh the query
await machineQuery.refetch();
};
return (
<Show when={machineQuery.isSuccess}>
<SidebarSectionForm
title="Tags"
schema={schema}
onSubmit={onSubmit}
initialValues={initialValues()}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</Field>
</div>
)}
</SidebarSectionForm>
</Show>
);
};

View File

@@ -28,7 +28,7 @@ export const Routes: RouteDefinition[] = [
path: "/",
},
{
path: "/machines/:machineName",
path: "/machines/:machineID",
component: Machine,
},
],

View File

@@ -1,154 +0,0 @@
import { Accessor, createEffect, createRoot } from "solid-js";
import { MachineRepr } from "./MachineRepr";
import * as THREE from "three";
import { SceneData } from "../stores/clan";
import { MachinesQueryResult } from "../hooks/queries";
import { ObjectRegistry } from "./ObjectRegistry";
import { renderLoop } from "./RenderLoop";
function keyFromPos(pos: [number, number]): string {
return `${pos[0]},${pos[1]}`;
}
const CUBE_SPACING = 2;
export class MachineManager {
public machines = new Map<string, MachineRepr>();
private disposeRoot: () => void;
private machinePositionsSignal: Accessor<SceneData>;
constructor(
scene: THREE.Scene,
registry: ObjectRegistry,
machinePositionsSignal: Accessor<SceneData>,
machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>,
setMachinePos: (id: string, position: [number, number]) => void,
) {
this.machinePositionsSignal = machinePositionsSignal;
this.disposeRoot = createRoot((disposeEffects) => {
createEffect(() => {
const machines = machinePositionsSignal();
Object.entries(machines).forEach(([id, data]) => {
const machineRepr = new MachineRepr(
scene,
registry,
new THREE.Vector2(data.position[0], data.position[1]),
id,
selectedIds,
);
this.machines.set(id, machineRepr);
scene.add(machineRepr.group);
});
renderLoop.requestRender();
});
// Push positions of previously existing machines to the scene
// TODO: Maybe we should do this in some post query hook?
createEffect(() => {
if (!machinesQueryResult.data) return;
const actualMachines = Object.keys(machinesQueryResult.data);
const machinePositions = machinePositionsSignal();
const placed: Set<string> = machinePositions
? new Set(Object.keys(machinePositions))
: new Set();
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
// Push not explizitly placed machines to the scene
// TODO: Make the user place them manually
// We just calculate some next free position
for (const id of nonPlaced) {
console.log("adding", id);
const position = this.nextGridPos();
setMachinePos(id, position);
}
});
return disposeEffects;
});
}
nextGridPos(): [number, number] {
const occupiedPositions = new Set(
Object.values(this.machinePositionsSignal()).map((data) =>
keyFromPos(data.position),
),
);
let x = 0,
z = 0;
let layer = 1;
while (layer < 100) {
// right
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x += 1;
}
// down
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z += 1;
}
layer++;
// left
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x -= 1;
}
// up
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z -= 1;
}
layer++;
}
console.warn("No free grid positions available, returning [0, 0]");
// Fallback if no position was found
return [0, 0] as [number, number];
}
dispose(scene: THREE.Scene) {
for (const machine of this.machines.values()) {
machine.dispose(scene);
}
// Stop SolidJS effects
this.disposeRoot?.();
// Clear references
this.machines?.clear();
}
}
// TODO: For service focus
// const getCirclePosition =
// (center: [number, number, number]) =>
// (_id: string, index: number, total: number): [number, number, number] => {
// const r = total === 1 ? 0 : Math.sqrt(total) * CUBE_SPACING; // Radius based on total cubes
// const x = Math.cos((index / total) * 2 * Math.PI) * r + center[0];
// const z = Math.sin((index / total) * 2 * Math.PI) * r + center[2];
// // Position cubes at y = 0.5 to float above the ground
// return [x, CUBE_Y, z];
// };

View File

@@ -1,141 +0,0 @@
import * as THREE from "three";
import { ObjectRegistry } from "./ObjectRegistry";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Accessor, createEffect, createRoot, on } from "solid-js";
import { renderLoop } from "./RenderLoop";
// Constants
const BASE_SIZE = 0.9;
const CUBE_SIZE = BASE_SIZE / 1.5;
const CUBE_HEIGHT = CUBE_SIZE;
const BASE_HEIGHT = 0.05;
const CUBE_COLOR = 0xd7e0e1;
const CUBE_EMISSIVE = 0x303030;
const CUBE_SELECTED_COLOR = 0x4b6767;
const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c;
const BASE_SELECTED_COLOR = 0x69b0e3;
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
export class MachineRepr {
public id: string;
public group: THREE.Group;
private cubeMesh: THREE.Mesh;
private baseMesh: THREE.Mesh;
private geometry: THREE.BoxGeometry;
private material: THREE.MeshPhongMaterial;
private disposeRoot: () => void;
constructor(
scene: THREE.Scene,
registry: ObjectRegistry,
position: THREE.Vector2,
id: string,
selectedSignal: Accessor<Set<string>>,
) {
this.id = id;
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
this.material = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
shininess: 100,
});
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
this.cubeMesh.castShadow = true;
this.cubeMesh.receiveShadow = true;
this.cubeMesh.userData = { id };
this.cubeMesh.name = "cube";
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2, 0);
this.baseMesh = this.createCubeBase(
BASE_COLOR,
BASE_EMISSIVE,
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
);
this.baseMesh.name = "base";
const label = this.createLabel(id);
this.cubeMesh.add(label);
this.group = new THREE.Group();
this.group.add(this.cubeMesh);
this.group.add(this.baseMesh);
this.group.position.set(position.x, 0, position.y);
this.group.userData.id = id;
this.disposeRoot = createRoot((disposeEffects) => {
createEffect(
on(selectedSignal, (selectedIds) => {
const isSelected = selectedIds.has(this.id);
// Update cube
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
);
// Update base
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
);
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
);
renderLoop.requestRender();
}),
);
return disposeEffects;
});
scene.add(this.group);
registry.add({
object: this.group,
id,
type: "machine",
dispose: () => this.dispose(scene),
});
}
private createCubeBase(
color: THREE.ColorRepresentation,
emissive: THREE.ColorRepresentation,
geometry: THREE.BoxGeometry,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
color,
emissive,
transparent: true,
opacity: 1,
});
const base = new THREE.Mesh(geometry, baseMaterial);
base.position.set(0, BASE_HEIGHT / 2, 0);
base.receiveShadow = true;
return base;
}
private createLabel(id: string) {
const div = document.createElement("div");
div.className = "machine-label";
div.textContent = id;
const label = new CSS2DObject(div);
label.position.set(0, CUBE_SIZE + 0.1, 0);
return label;
}
dispose(scene: THREE.Scene) {
this.disposeRoot?.(); // Stop SolidJS effects
scene.remove(this.group);
this.geometry.dispose();
this.material.dispose();
(this.baseMesh.material as THREE.Material).dispose();
}
}

View File

@@ -1,43 +0,0 @@
import * as THREE from "three";
interface ObjectEntry {
object: THREE.Object3D;
type: string;
id: string;
dispose?: () => void;
}
export class ObjectRegistry {
#objects = new Map<string, ObjectEntry>();
add(entry: ObjectEntry) {
const key = `${entry.type}:${entry.id}`;
this.#objects.set(key, entry);
}
getById(type: string, id: string) {
return this.#objects.get(`${type}:${id}`);
}
getAllByType(type: string) {
return [...this.#objects.values()].filter((obj) => obj.type === type);
}
removeById(type: string, id: string, scene: THREE.Scene) {
const key = `${type}:${id}`;
const entry = this.#objects.get(key);
if (entry) {
scene.remove(entry.object);
entry.dispose?.();
this.#objects.delete(key);
}
}
disposeAll(scene: THREE.Scene) {
for (const entry of this.#objects.values()) {
scene.remove(entry.object);
entry.dispose?.();
}
this.#objects.clear();
}
}

View File

@@ -1,133 +0,0 @@
import { Scene, Camera, WebGLRenderer } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
/**
* Private class to manage the render loop
* @internal
*/
class RenderLoop {
// Track if a render is already requested
// This prevents multiple requests in the same frame
// and ensures only one render per frame
// This is important for performance and to avoid flickering
private renderRequested = false;
// References to the scene, camera, renderer, controls, and label renderer
// These will be set during initialization
private scene!: Scene;
private bgScene!: Scene;
private camera!: Camera;
private bgCamera!: Camera;
private renderer!: WebGLRenderer;
private controls!: MapControls;
private labelRenderer!: CSS2DRenderer;
// Flag to prevent multiple initializations
private initialized = false;
init(
scene: Scene,
camera: Camera,
renderer: WebGLRenderer,
labelRenderer: CSS2DRenderer,
controls: MapControls,
bgScene: Scene,
bgCamera: Camera,
) {
if (this.initialized) {
console.error("RenderLoop already initialized.");
return;
}
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
this.controls = controls;
this.bgScene = bgScene;
this.bgCamera = bgCamera;
this.labelRenderer = labelRenderer;
this.initialized = true;
}
requestRender() {
// If not initialized, log an error and return
if (!this.initialized) {
console.error(
"RenderLoop not initialized yet. Make sure to call init() once before usage.",
);
return;
}
// If a render is already requested, do nothing
if (this.renderRequested) return;
this.renderRequested = true;
requestAnimationFrame(() => {
this.updateTweens();
const needsUpdate = this.controls.update(); // returns true if damping is ongoing
this.render();
this.renderRequested = false;
if (needsUpdate) {
this.requestRender();
}
});
}
private updateTweens() {
// TODO: TWEEN.update() for tween animations in the future
}
private render() {
// TODO: Disable console.debug in production
// console.debug("Rendering scene...", this);
this.renderer.clear();
this.renderer.render(this.bgScene, this.bgCamera);
this.renderer.render(this.scene, this.camera);
this.labelRenderer.render(this.scene, this.camera);
}
dispose() {
// Dispose controls, renderer, remove listeners if any
this.controls.dispose();
this.renderer.dispose();
// clear refs, this prevents memory leaks by allowing garbage collection
this.scene = null!;
this.bgScene = null!;
this.camera = null!;
this.bgCamera = null!;
this.renderer = null!;
this.controls = null!;
this.labelRenderer = null!;
this.initialized = false;
}
}
/**
* Singleton instance of RenderLoop
* This is used to manage the re-rendering
*
* It can only be initialized once then passed to individual components
* they can use the renderLoop to request re-renders as needed.
*
*
* Usage:
* ```typescript
* import { renderLoop } from "./RenderLoop";
*
* // Somewhere initialize the render loop:
* renderLoop.init(scene, camera, renderer, labelRenderer, controls, bgScene, bgCamera);
*
* // To request a render:
* renderLoop.requestRender();
*
* // To dispose:
* onCleanup(() => {
* renderLoop.dispose();
* })
*
*/
export const renderLoop = new RenderLoop();

View File

@@ -8,19 +8,3 @@
@apply absolute bottom-8 z-10 w-full;
@apply flex justify-center items-center;
}
.machine-label {
@apply text-white bg-inv-4 py-1 px-2 rounded-sm;
font-size: 0.75rem;
}
.machine-label::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #203637 transparent transparent transparent;
}

View File

@@ -1,19 +1,23 @@
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
import {
createSignal,
createEffect,
onCleanup,
onMount,
createMemo,
on,
} from "solid-js";
import "./cubes.css";
import * as THREE from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { MachinesQueryResult } from "../hooks/queries";
import { MachinesQueryResult } from "../queries/queries";
import { SceneData } from "../stores/clan";
import { unwrap } from "solid-js/store";
import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager";
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
@@ -31,6 +35,35 @@ function garbageCollectGroup(group: THREE.Group) {
group.clear(); // Clear the group
}
function getFloorPosition(
camera: THREE.Camera,
floor: THREE.Object3D,
): [number, number, number] {
const cameraPosition = camera.position.clone();
// Get camera's direction
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
// Define floor plane (XZ-plane at y=0)
const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Normal = up, constant = 0
// Create ray from camera
const ray = new THREE.Ray(cameraPosition, direction);
// Get intersection point
const intersection = new THREE.Vector3();
ray.intersectPlane(floorPlane, intersection);
return intersection.toArray() as [number, number, number];
}
function keyFromPos(pos: [number, number]): string {
return `${pos[0]},${pos[1]}`;
}
// type SceneDataUpdater = (sceneData: SceneData) => void;
export function CubeScene(props: {
cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>;
@@ -44,22 +77,32 @@ export function CubeScene(props: {
let scene: THREE.Scene;
let camera: THREE.OrthographicCamera;
let renderer: THREE.WebGLRenderer;
let labelRenderer: CSS2DRenderer;
let floor: THREE.Mesh;
let controls: MapControls;
// Raycaster for clicking
const raycaster = new THREE.Raycaster();
let initBase: THREE.Mesh | undefined;
let needsRender = false; // Flag to control rendering
// Create background scene
const bgScene = new THREE.Scene();
const bgCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const groupMap = new Map<string, THREE.Group>();
const occupiedPositions = new Set<string>();
let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry;
// Used for development purposes
// Vite does hot-reload but we need to ensure the animation loop doesn't run multiple times
// This flag prevents multiple animation loops from running simultaneously
// It is set to true when the component mounts and false when it unmounts
let isAnimating = false; // Flag to prevent multiple loops
let frameCount = 0;
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid",
);
@@ -73,8 +116,12 @@ export function CubeScene(props: {
spherical: { radius: 0, theta: 0, phi: 0 },
});
// Animation configuration
const ANIMATION_DURATION = 800; // milliseconds
// Grid configuration
const GRID_SIZE = 2;
const CUBE_SPACING = 2;
const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; //
@@ -84,12 +131,215 @@ export function CubeScene(props: {
const FLOOR_COLOR = 0xcdd8d9;
const CUBE_COLOR = 0xd7e0e1;
const CUBE_EMISSIVE = 0x303030; // Emissive color for cubes
const CUBE_SELECTED_COLOR = 0x4b6767;
const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c;
const BASE_SELECTED_COLOR = 0x69b0e3;
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7;
createEffect(() => {
// Update when API updates.
if (props.cubesQuery.data) {
const actualMachines = Object.keys(props.cubesQuery.data);
const rawStored = unwrap(props.sceneStore());
const placed: Set<string> = rawStored
? new Set(Object.keys(rawStored))
: new Set();
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
// Initialize occupied positions from previously placed cubes
for (const id of placed) {
occupiedPositions.add(keyFromPos(rawStored[id].position));
}
// Push not explizitly placed machines to the scene
// TODO: Make the user place them manually
// We just calculate some next free position
for (const id of nonPlaced) {
console.log("adding", id);
const position = nextGridPos();
console.log("Got pos", position);
// Add the machine to the store
// Adding it triggers a reactive update
props.setMachinePos(id, position);
occupiedPositions.add(keyFromPos(position));
}
}
});
function requestRenderIfNotRequested() {
if (!needsRender) {
needsRender = true;
requestAnimationFrame(renderScene);
}
}
function renderScene() {
if (!isAnimating) {
console.warn("Not animating!");
return;
}
console.log("Rendering scene...", camera.toJSON());
needsRender = false;
frameCount++;
renderer.autoClear = false;
renderer.render(bgScene, bgCamera);
controls.update(); // optional; see note below
renderer.render(scene, camera);
if (frameCount % 30 === 0) logMemoryUsage();
}
function getGridPosition(id: string): [number, number, number] {
// TODO: Detect collision with other cubes
const machine = props.sceneStore()[id];
console.log("getGridPosition", id, machine);
if (machine) {
return [machine.position[0], 0, machine.position[1]];
}
// Some fallback to get the next free position
// If the position wasn't avilable in the store
console.warn(`Position for ${id} not set`);
return [0, 0, 0];
}
function nextGridPos(): [number, number] {
let x = 0,
z = 0;
let layer = 1;
while (layer < 100) {
// right
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x += 1;
}
// down
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z += 1;
}
layer++;
// left
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x -= 1;
}
// up
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z -= 1;
}
layer++;
}
console.warn("No free grid positions available, returning [0, 0]");
// Fallback if no position was found
return [0, 0] as [number, number];
}
// Circle IDEA:
// Need to talk with timo and W about this
const getCirclePosition =
(center: [number, number, number]) =>
(_id: string, index: number, total: number): [number, number, number] => {
const r = total === 1 ? 0 : Math.sqrt(total) * CUBE_SPACING; // Radius based on total cubes
const x = Math.cos((index / total) * 2 * Math.PI) * r + center[0];
const z = Math.sin((index / total) * 2 * Math.PI) * r + center[2];
// Position cubes at y = 0.5 to float above the ground
return [x, CUBE_Y, z];
};
// Reactive cubes memo - this recalculates whenever data changes
const cubes = createMemo(() => {
console.log("Calculating cubes...");
const sceneData = props.sceneStore(); // keep it reactive
if (!sceneData) return [];
const currentIds = Object.keys(sceneData);
console.log("Current IDs:", currentIds);
let cameraTarget = [0, 0, 0] as [number, number, number];
if (camera && floor) {
cameraTarget = getFloorPosition(camera, floor);
}
const getCubePosition =
positionMode() === "grid"
? getGridPosition
: getCirclePosition(cameraTarget);
return currentIds.map((id, index) => {
const activeIndex = currentIds.indexOf(id);
const position = getCubePosition(id, index, currentIds.length);
const targetPosition =
activeIndex >= 0
? getCubePosition(id, activeIndex, currentIds.length)
: getCubePosition(id, index, currentIds.length);
return {
id,
position,
targetPosition,
};
});
});
// Animation helper function
function animateToPosition(
thing: THREE.Object3D,
targetPosition: [number, number, number],
duration: number = ANIMATION_DURATION,
) {
const startPosition = thing.position.clone();
const endPosition = new THREE.Vector3(...targetPosition);
const startTime = Date.now();
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Smooth easing function
const easeProgress = 1 - Math.pow(1 - progress, 3);
thing.position.lerpVectors(startPosition, endPosition, easeProgress);
if (progress < 1) {
requestAnimationFrame(animate);
requestRenderIfNotRequested();
}
}
animate();
}
function createCubeBase(
cube_pos: [number, number, number],
opacity = 1,
@@ -115,6 +365,80 @@ export function CubeScene(props: {
props.onSelect(next);
}
function updateMeshColors(
selected: Set<string>,
prev: Set<string> | undefined,
) {
for (const id of selected) {
const group = groupMap.get(id);
if (!group) {
console.warn(`UPDATE COLORS: Group not found for id: ${id}`);
continue;
}
const base = group.children.find((child) => child.name === "base");
if (!base || !(base instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Base mesh not found for id: ${id}`);
continue;
}
const cube = group.children.find((child) => child.name === "cube");
if (!cube || !(cube instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Cube mesh not found for id: ${id}`);
continue;
}
const baseMaterial = base.material as THREE.MeshPhongMaterial;
const cubeMaterial = cube.material as THREE.MeshPhongMaterial;
baseMaterial.color.set(BASE_SELECTED_COLOR);
baseMaterial.emissive.set(BASE_SELECTED_EMISSIVE);
cubeMaterial.color.set(CUBE_SELECTED_COLOR);
}
const deselected = Array.from(prev || []).filter((s) => !selected.has(s));
for (const id of deselected) {
const group = groupMap.get(id);
if (!group) {
console.warn(`UPDATE COLORS: Group not found for id: ${id}`);
continue;
}
const base = group.children.find((child) => child.name === "base");
if (!base || !(base instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Base mesh not found for id: ${id}`);
continue;
}
const cube = group.children.find((child) => child.name === "cube");
if (!cube || !(cube instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Cube mesh not found for id: ${id}`);
continue;
}
const baseMaterial = base.material as THREE.MeshPhongMaterial;
const cubeMaterial = cube.material as THREE.MeshPhongMaterial;
baseMaterial.color.set(BASE_COLOR);
baseMaterial.emissive.set(BASE_EMISSIVE);
cubeMaterial.color.set(CUBE_COLOR);
}
requestRenderIfNotRequested();
}
function logMemoryUsage() {
if (renderer && renderer.info) {
console.debug("Three.js Memory:", {
frame: renderer.info.render.frame,
calls: renderer.info.render.calls,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
programs: renderer.info.programs?.length || 0,
triangles: renderer.info.render.triangles,
});
}
}
const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3(
@@ -197,22 +521,13 @@ export function CubeScene(props: {
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.autoClear = false;
container.appendChild(renderer.domElement);
// Label renderer
labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none";
labelRenderer.domElement.style.zIndex = "0";
container.appendChild(labelRenderer.domElement);
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
// Enable the context menu,
// TODO: disable in production
controls.mouseButtons.RIGHT = null;
controls.enableRotate = false;
controls.minZoom = 1.2;
controls.maxZoom = 3.5;
controls.addEventListener("change", () => {
@@ -223,51 +538,42 @@ export function CubeScene(props: {
camera.top = d / zoom;
camera.bottom = -d / zoom;
camera.updateProjectionMatrix();
renderLoop.requestRender();
});
renderLoop.init(
scene,
camera,
renderer,
labelRenderer,
controls,
bgScene,
bgCamera,
);
requestRenderIfNotRequested();
});
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
// scene.add(new THREE.DirectionalLightHelper(directionalLight));
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical(
15,
1000,
initialSphericalCameraPosition.phi - Math.PI / 8,
initialSphericalCameraPosition.theta - Math.PI / 2,
);
directionalLight.position.setFromSpherical(lightPos);
directionalLight.rotation.set(0, 0, 0);
directionalLight.target.position.set(0, 0, 0); // Point light at the center
// initialSphericalCameraPosition
directionalLight.castShadow = true;
// Configure shadow camera for hard, crisp shadows
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.left = -30;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30;
directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 30;
directionalLight.shadow.camera.far = 2000;
directionalLight.shadow.mapSize.width = 4096; // Higher resolution for sharper shadows
directionalLight.shadow.mapSize.height = 4096;
directionalLight.shadow.radius = 1; // Hard shadows (low radius)
directionalLight.shadow.radius = 0; // Hard shadows (low radius)
directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges
scene.add(directionalLight);
scene.add(directionalLight.target);
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// Floor/Ground - Make it invisible but keep it for reference
const floorGeometry = new THREE.PlaneGeometry(1000, 1000);
@@ -344,21 +650,10 @@ export function CubeScene(props: {
} else {
initBase!.visible = false;
}
renderLoop.requestRender();
requestRenderIfNotRequested();
}),
);
const registry = new ObjectRegistry();
const machineManager = new MachineManager(
scene,
registry,
props.sceneStore,
props.cubesQuery,
props.selectedIds,
props.setMachinePos,
);
// Click handler:
// - Select/deselects a cube in "view" mode
// - Creates a new cube in "create" mode
@@ -393,11 +688,10 @@ export function CubeScene(props: {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
Array.from(groupMap.values()),
);
console.log("Intersects:", intersects);
if (intersects.length > 0) {
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id;
toggleSelection(id);
} else {
@@ -407,7 +701,9 @@ export function CubeScene(props: {
renderer.domElement.addEventListener("click", onClick);
renderLoop.requestRender();
isAnimating = true;
requestRenderIfNotRequested();
// Handle window resize
const handleResize = () => {
@@ -420,7 +716,6 @@ export function CubeScene(props: {
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.setSize(container.clientWidth, container.clientHeight);
// Update background shader resolution
uniforms.resolution.value.set(
@@ -428,8 +723,8 @@ export function CubeScene(props: {
container.clientHeight,
);
// renderer.render(bgScene, bgCamera);
renderLoop.requestRender();
renderer.render(bgScene, bgCamera);
requestRenderIfNotRequested();
};
renderer.domElement.addEventListener("mousemove", onMouseMove);
@@ -445,27 +740,10 @@ export function CubeScene(props: {
{ capture: true },
);
// Initial render
renderLoop.requestRender();
// Cleanup function
onCleanup(() => {
for (const group of groupMap.values()) {
garbageCollectGroup(group);
scene.remove(group);
}
groupMap.clear();
// Dispose shared geometries
sharedCubeGeometry?.dispose();
sharedBaseGeometry?.dispose();
renderer?.dispose();
renderLoop.dispose();
machineManager.dispose(scene);
// Stop animation loop
isAnimating = false;
renderer.domElement.removeEventListener("click", onClick);
renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize);
@@ -482,13 +760,145 @@ export function CubeScene(props: {
if (container) {
container.innerHTML = "";
}
groupMap.forEach((group) => {
garbageCollectGroup(group);
scene.remove(group);
});
groupMap.clear();
});
});
function createCube(
gridPosition: [number, number],
userData: { id: string },
) {
// Creates a cube, base, and other visuals
// Groups them together in the scene
const cubeMaterial = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
// specular: 0xffffff,
shininess: 100,
});
const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterial);
cubeMesh.castShadow = true;
cubeMesh.receiveShadow = true;
cubeMesh.userData = userData;
cubeMesh.name = "cube"; // Name for easy identification
cubeMesh.position.set(0, CUBE_Y, 0);
const baseMesh = createCubeBase([0, BASE_HEIGHT / 2, 0]);
baseMesh.name = "base"; // Name for easy identification
// TODO: Destroy Group in onCleanup
const group = new THREE.Group();
group.add(cubeMesh);
group.add(baseMesh);
group.position.set(gridPosition[0], 0, gridPosition[1]); // Position on the grid
group.userData.id = userData.id;
return group;
}
// Effect to manage cube meshes - this runs whenever cubes() changes
createEffect(() => {
const currentCubes = cubes();
const existing = new Set(groupMap.keys());
// Update existing cubes and create new ones
currentCubes.forEach((cube) => {
const existingGroup = groupMap.get(cube.id);
console.log(
"Processing cube:",
cube.id,
"Existing group:",
existingGroup,
);
if (!existingGroup) {
const group = createCube([cube.position[0], cube.position[2]], {
id: cube.id,
});
scene.add(group);
groupMap.set(cube.id, group);
} else {
// Only animate position if not being deleted
const targetPosition = cube.targetPosition || cube.position;
const currentPosition = existingGroup.position.toArray() as [
number,
number,
number,
];
const target = targetPosition;
// Check if position actually changed
if (
Math.abs(currentPosition[0] - target[0]) > 0.01 ||
Math.abs(currentPosition[1] - target[1]) > 0.01 ||
Math.abs(currentPosition[2] - target[2]) > 0.01
) {
animateToPosition(existingGroup, target);
}
}
existing.delete(cube.id);
});
// Remove cubes that are no longer in the state and not being deleted
existing.forEach((id) => {
if (!currentCubes.find((d) => d.id == id)) {
const group = groupMap.get(id);
if (group) {
console.log("Cleaning...", id);
garbageCollectGroup(group);
scene.remove(group);
groupMap.delete(id);
const pos = group.position.toArray() as [number, number, number];
occupiedPositions.delete(keyFromPos([pos[0], pos[2]]));
}
}
});
requestRenderIfNotRequested();
});
createEffect(
on(props.selectedIds, (curr, prev) => {
console.log("Selected cubes:", curr);
// Update colors of selected cubes
updateMeshColors(curr, prev);
}),
);
onCleanup(() => {
for (const group of groupMap.values()) {
garbageCollectGroup(group);
scene.remove(group);
}
groupMap.clear();
// Dispose shared geometries
sharedCubeGeometry?.dispose();
sharedBaseGeometry?.dispose();
renderer?.dispose();
});
const onHover = (inside: boolean) => (event: MouseEvent) => {
const pos = nextGridPos();
if (!initBase) return;
if (initBase.visible === false && inside) {
initBase.position.set(pos[0], BASE_HEIGHT / 2, pos[1]);
initBase.visible = true;
}
requestRenderIfNotRequested();
};
const onAddClick = (event: MouseEvent) => {
setPositionMode("grid");
setWorldMode("create");
renderLoop.requestRender();
};
const onMouseMove = (event: MouseEvent) => {
if (worldMode() !== "create") return;
@@ -519,7 +929,7 @@ export function CubeScene(props: {
// Only request render if the position actually changed
initBase.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
renderLoop.requestRender();
requestRenderIfNotRequested();
}
}
};
@@ -555,7 +965,6 @@ export function CubeScene(props: {
setPositionMode("grid");
grid.visible = true;
}
renderLoop.requestRender();
}}
/>
<ToolbarButton name="delete" icon="Trash" />

View File

@@ -1,4 +1,4 @@
.splash {
#splash {
position: fixed;
inset: 0;
background: linear-gradient(to top, #e3e7e7, #edf1f1);
@@ -9,17 +9,17 @@
pointer-events: none;
}
.splash_content {
#splash .content {
display: flex;
flex-direction: column;
align-items: center;
}
.splash_title {
.title {
@apply h-8 mb-8;
}
.loading_bar {
.loader {
@apply h-3 w-60 mb-3;
width: 18rem;
background: repeating-linear-gradient(

View File

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

Some files were not shown because too many files have changed in this diff Show More