Compare commits

..

1 Commits

Author SHA1 Message Date
pinpox
3ac9167f18 expose metrics as json 2025-08-20 11:04:01 +02:00
323 changed files with 2816 additions and 4825 deletions

View File

@@ -8,7 +8,7 @@ Our mission is simple: to democratize computing by providing tools that empower
## Features of Clan
- **Full-Stack System Deployment:** Utilize Clan's toolkit alongside Nix's reliability to build and manage systems effortlessly.
- **Full-Stack System Deployment:** Utilize Clans toolkit alongside Nix's reliability to build and manage systems effortlessly.
- **Overlay Networks:** Secure, private communication channels between devices.
- **Virtual Machine Integration:** Seamless operation of VM applications within the main operating system.
- **Robust Backup Management:** Long-term, self-hosted data preservation.

View File

@@ -232,7 +232,6 @@
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
"--update-hardware-config", "nixos-facter",
"--no-persist-state",
]
subprocess.run(clan_cmd, check=True)
@@ -276,7 +275,7 @@
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${closureInfo}"
)
# Set up SSH connection
ssh_conn = setup_ssh_connection(
target,

View File

@@ -11,8 +11,7 @@
...
}:
let
jsonpath = "/tmp/telegraf.json";
auth_user = "prometheus";
jsonpath = /tmp/telegraf.json;
in
{
@@ -20,24 +19,18 @@
builtins.listToAttrs (
map (name: {
inherit name;
value.allowedTCPPorts = [
9273
9990
];
value.allowedTCPPorts = [ 9273 9990 ];
}) settings.interfaces
)
);
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [
9273
9990
];
systemd.services.telegsaf-json.script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath}";
clan.core.vars.generators."telegraf" = {
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [ 9273 ];
files.password.restartUnits = [ "telegraf.service" ];
files.password-env.restartUnits = [ "telegraf.service" ];
files.miniserve-auth.restartUnits = [ "telegraf.service" ];
clan.core.vars.generators."telegraf-password" = {
files.telegraf-password.neededFor = "users";
files.telegraf-password.restartUnits = [ "telegraf.service" ];
runtimeInputs = [
pkgs.coreutils
@@ -47,22 +40,16 @@
script = ''
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/password-env
echo "${auth_user}:$PASSWORD" > "$out"/miniserve-auth
echo "$PASSWORD" | tr -d "\n" > "$out"/password
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/telegraf-password
'';
};
systemd.services.telegraf-json = {
enable = true;
wantedBy = [ "multi-user.target" ];
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
};
services.telegraf = {
enable = true;
environmentFiles = [
(builtins.toString config.clan.core.vars.generators.telegraf.files.password-env.path)
(builtins.toString
config.clan.core.vars.generators."telegraf-password".files.telegraf-password.path
)
];
extraConfig = {
agent.interval = "60s";
@@ -77,35 +64,32 @@
exec =
let
nixosSystems = pkgs.writeShellScript "current-system" ''
printf "nixos_systems,current_system=%s,booted_system=%s,current_kernel=%s,booted_kernel=%s present=0\n" \
"$(readlink /run/current-system)" "$(readlink /run/booted-system)" \
"$(basename $(echo /run/current-system/kernel-modules/lib/modules/*))" \
"$(basename $(echo /run/booted-system/kernel-modules/lib/modules/*))"
currentSystemScript = pkgs.writeShellScript "current-system" ''
printf "current_system,path=%s present=0\n" $(readlink /run/current-system)
'';
in
[
{
# Expose the path to current-system as metric. We use
# this to check if the machine is up-to-date.
commands = [ nixosSystems ];
commands = [ currentSystemScript ];
data_format = "influx";
}
];
};
# sadly there doesn'T seem to exist a telegraf http_client output plugin
outputs.prometheus_client = {
listen = ":9273";
metric_version = 2;
basic_username = "${auth_user}";
basic_password = "$${BASIC_AUTH_PWD}";
};
outputs.file = {
files = [ jsonpath ];
data_format = "json";
json_timestamp_units = "1s";
};
outputs.prometheus_client = {
listen = ":9273";
metric_version = 2;
basic_username = "prometheus";
basic_password = "$${BASIC_AUTH_PWD}";
};
};
};
};

View File

@@ -17,20 +17,6 @@
};
};
# Deploy user Carol on all machines. Prompt only once and use the
# same password on all machines. (`share = true`)
user-carol = {
module = {
name = "users";
input = "clan";
};
roles.default.tags.all = { };
roles.default.settings = {
user = "carol";
share = true;
};
};
# Deploy user bob only on his laptop. Prompt for a password.
user-bob = {
module = {
@@ -43,44 +29,3 @@
};
}
```
## Migration from `root-password` module
The deprecated `clan.root-password` module has been replaced by the `users` module. Here's how to migrate:
### 1. Update your flake configuration
Replace the `root-password` module import with a `users` service instance:
```nix
# OLD - Remove this from your nixosModules:
imports = [
self.inputs.clan-core.clanModules.root-password
];
# NEW - Add to inventory.instances or machines/flake-module.nix:
instances = {
users-root = {
module.name = "users";
module.input = "clan-core";
roles.default.tags.nixos = { };
roles.default.settings = {
user = "root";
prompt = false; # Set to true if you want to be prompted
groups = [ ];
};
};
};
```
### 2. Migrate vars
The vars structure has changed from `root-password` to `user-password-root`:
```bash
# For each machine, rename the vars directories:
cd vars/per-machine/<machine-name>/
mv root-password user-password-root
mv user-password-root/password-hash user-password-root/user-password-hash
mv user-password-root/password user-password-root/user-password
```

View File

@@ -59,17 +59,6 @@
- "input" - Allows the user to access input devices.
'';
};
share = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Weather the user should have the same password on all machines.
By default, you will be prompted for a new password for every host.
Unless `generate` is set to `true`.
'';
};
};
};
@@ -93,6 +82,7 @@
};
clan.core.vars.generators."user-password-${settings.user}" = {
files.user-password-hash.neededFor = "users";
files.user-password-hash.restartUnits = lib.optional (config.services.userborn.enable) "userborn.service";
files.user-password.deploy = false;
@@ -117,8 +107,6 @@
pkgs.mkpasswd
];
share = settings.share;
script =
(
if settings.prompt then

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""IPv6 address allocator for WireGuard networks.
"""
IPv6 address allocator for WireGuard networks.
Network layout:
- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
@@ -19,7 +20,8 @@ def hash_string(s: str) -> str:
def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
"""Generate a /40 ULA prefix from instance name.
"""
Generate a /40 ULA prefix from instance name.
Format: fd{32-bit hash}/40
This gives us fd00:0000:0000::/40 through fdff:ffff:ff00::/40
@@ -44,10 +46,10 @@ def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
def generate_controller_subnet(
base_network: ipaddress.IPv6Network,
controller_name: str,
base_network: ipaddress.IPv6Network, controller_name: str
) -> ipaddress.IPv6Network:
"""Generate a /56 subnet for a controller from the base /40 network.
"""
Generate a /56 subnet for a controller from the base /40 network.
We have 16 bits (40 to 56) to allocate controller subnets.
This allows for 65,536 possible controller subnets.
@@ -66,7 +68,8 @@ def generate_controller_subnet(
def generate_peer_suffix(peer_name: str) -> str:
"""Generate a unique 64-bit host suffix for a peer.
"""
Generate a unique 64-bit host suffix for a peer.
This suffix will be used in all controller subnets to create unique addresses.
Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
@@ -83,7 +86,7 @@ def generate_peer_suffix(peer_name: str) -> str:
def main() -> None:
if len(sys.argv) < 4:
print(
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>",
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>"
)
sys.exit(1)

18
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1756081310,
"narHash": "sha256-wj1H5Pr6w4AsB+nG3K07SgSIDZ7jDCkGnh5XXWLdtk8=",
"lastModified": 1755649112,
"narHash": "sha256-Tk/Sjlb7W+5usS4upH0la4aGYs2velkHADnhhn2xO88=",
"ref": "main",
"rev": "7b926d43dc361cd8d3ad3c14a2e7e75375b7d215",
"rev": "bdab3e23af4a9715dfa6346f344f1bd428508672",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -84,11 +84,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1756050191,
"narHash": "sha256-lMtTT4rv5On7D0P4Z+k7UkvbAKKuVGRbJi/VJeRCQwI=",
"lastModified": 1755628699,
"narHash": "sha256-IAM29K+Cz9pu90lDDkJpmOpWzQ9Ed0FNkRJcereY+rM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "759dcc6981cd4aa222d36069f78fe7064d563305",
"rev": "01b1b3809cfa3b0fbca8d75c9cf4b2efdb8182cf",
"type": "github"
},
"original": {
@@ -165,11 +165,11 @@
"nixpkgs": []
},
"locked": {
"lastModified": 1755934250,
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"lastModified": 1754847726,
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
"type": "github"
},
"original": {

2
docs/.gitignore vendored
View File

@@ -1,5 +1,5 @@
/site/reference
/site/static
/site/options
/site/options-page
/site/openapi.json
!/site/static/extra.css

View File

@@ -6,7 +6,7 @@ edit_uri: _edit/main/docs/docs/
validation:
omitted_files: warn
absolute_links: ignore
absolute_links: warn
unrecognized_links: warn
markdown_extensions:
@@ -64,7 +64,7 @@ nav:
- Disk Encryption: guides/disk-encryption.md
- Age Plugins: guides/age-plugins.md
- Secrets management: guides/secrets.md
- Networking: guides/networking.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
@@ -78,7 +78,7 @@ nav:
- Writing a Disko Template: guides/disko-templates/community.md
- Migrations:
- Migrate existing Flakes: guides/migrations/migration-guide.md
- Migrate from clan modules to services: guides/migrations/migrate-inventory-services.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:
@@ -88,7 +88,7 @@ nav:
- Templates: concepts/templates.md
- Reference:
- Overview: reference/index.md
- Browse Options: "/options"
- Clan Options: options.md
- Services:
- Overview:
- reference/clanServices/index.md
@@ -155,7 +155,6 @@ nav:
- 05-deployment-parameters: decisions/05-deployment-parameters.md
- Template: decisions/_template.md
- Glossary: reference/glossary.md
- Browse Options: "/options"
docs_dir: site
site_dir: out

View File

@@ -54,9 +54,9 @@ pkgs.stdenv.mkDerivation {
chmod -R +w ./site/reference
echo "Generated API documentation in './site/reference/' "
rm -rf ./site/options
cp -r ${docs-options} ./site/options
chmod -R +w ./site/options
rm -r ./site/options-page || true
cp -r ${docs-options} ./site/options-page
chmod -R +w ./site/options-page
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js

View File

@@ -25,7 +25,7 @@
serviceModules = self.clan.modules;
baseHref = "/options/";
baseHref = "/options-page/";
getRoles =
module:
@@ -126,7 +126,7 @@
nestedSettingsOption = mkOption {
type = types.raw;
description = ''
See [instances.${name}.roles.${roleName}.settings](${baseHref}?option_scope=0&option=inventory.instances.${name}.roles.${roleName}.settings)
See [instances.${name}.roles.${roleName}.settings](${baseHref}?option_scope=0&option=instances.${name}.roles.${roleName}.settings)
'';
};
settingsOption = mkOption {
@@ -161,42 +161,6 @@
}
];
baseModule =
# Module
{ config, ... }:
{
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
nixpkgs.pkgs = pkgs;
clan.core.name = "dummy";
system.stateVersion = config.system.nixos.release;
# Set this to work around a bug where `clan.core.settings.machine.name`
# is forced due to `networking.interfaces` being forced
# somewhere in the nixpkgs options
facter.detected.dhcp.enable = lib.mkForce false;
};
evalClanModules =
let
evaled = lib.evalModules {
class = "nixos";
modules = [
baseModule
{
clan.core.settings.directory = self;
}
self.nixosModules.clanCore
];
};
in
evaled;
coreOptions =
(pkgs.nixosOptionsDoc {
options = (evalClanModules.options).clan.core or { };
warningsAreErrors = true;
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
}).optionsJSON;
in
{
# Uncomment for debugging
@@ -211,17 +175,10 @@
# scopes = mapAttrsToList mkScope serviceModules;
scopes = [
{
inherit baseHref;
name = "Flake Options (clan.nix file)";
name = "Clan";
modules = docModules;
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
}
{
name = "Machine Options (clan.core NixOS options)";
optionsJSON = "${coreOptions}/share/doc/nixos/options.json";
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
}
];
};
};

View File

@@ -32,7 +32,7 @@ from typing import Any
from clan_lib.errors import ClanError
from clan_lib.services.modules import (
CategoryInfo,
ModuleManifest,
Frontmatter,
)
# Get environment variables
@@ -66,7 +66,8 @@ def render_option_header(name: str) -> str:
def join_lines_with_indentation(lines: list[str], indent: int = 4) -> str:
"""Joins multiple lines with a specified number of whitespace characters as indentation.
"""
Joins multiple lines with a specified number of whitespace characters as indentation.
Args:
lines (list of str): The lines of text to join.
@@ -74,7 +75,6 @@ def join_lines_with_indentation(lines: list[str], indent: int = 4) -> str:
Returns:
str: The indented and concatenated string.
"""
# Create the indentation string (e.g., four spaces)
indent_str = " " * indent
@@ -161,10 +161,7 @@ def render_option(
def print_options(
options_file: str,
head: str,
no_options: str,
replace_prefix: str | None = None,
options_file: str, head: str, no_options: str, replace_prefix: str | None = None
) -> str:
res = ""
with (Path(options_file) / "share/doc/nixos/options.json").open() as f:
@@ -179,8 +176,9 @@ def print_options(
return res
def module_header(module_name: str) -> str:
return f"# {module_name}\n\n"
def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
indicator = " 🔹" if has_inventory_feature else ""
return f"# {module_name}{indicator}\n\n"
clan_core_descr = """
@@ -238,7 +236,7 @@ def produce_clan_core_docs() -> None:
for submodule_name, split_options in split.items():
outfile = f"{module_name}/{submodule_name}.md"
print(
f"[clan_core.{submodule_name}] Rendering option of: {submodule_name}... {outfile}",
f"[clan_core.{submodule_name}] Rendering option of: {submodule_name}... {outfile}"
)
init_level = 1
root = options_to_tree(split_options, debug=True)
@@ -273,9 +271,56 @@ def produce_clan_core_docs() -> None:
of.write(output)
def render_roles(roles: list[str] | None, module_name: str) -> str:
if roles:
roles_list = "\n".join([f"- `{r}`" for r in roles])
return (
f"""
### Roles
This module can be used via predefined roles
{roles_list}
"""
"""
Every role has its own configuration options, which are each listed below.
For more information, see the [inventory guide](../../concepts/inventory.md).
??? Example
For example the `admin` module adds the following options globally to all machines where it is used.
`clan.admin.allowedkeys`
```nix
clan-core.lib.clan {
inventory.services = {
admin.me = {
roles.default.machines = [ "jon" ];
config.allowedkeys = [ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD..." ];
};
};
};
```
"""
)
return ""
clan_modules_descr = """
Clan modules are [NixOS modules](https://wiki.nixos.org/wiki/NixOS_modules)
which have been enhanced with additional features provided by Clan, with
certain option types restricted to enable configuration through a graphical
interface.
!!! note "🔹"
Modules with this indicator support the [inventory](../../concepts/inventory.md) feature.
"""
def render_categories(
categories: list[str],
categories_info: dict[str, CategoryInfo],
categories: list[str], categories_info: dict[str, CategoryInfo]
) -> str:
res = """<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">"""
for cat in categories:
@@ -340,10 +385,10 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
# output += f"`clan.modules.{module_name}`\n"
output += f"*{module_info['manifest']['description']}*\n"
fm = Frontmatter("")
# output += "## Categories\n\n"
output += render_categories(
module_info["manifest"]["categories"],
ModuleManifest.categories_info(),
module_info["manifest"]["categories"], fm.categories_info
)
output += f"{module_info['manifest']['readme']}\n"
@@ -352,7 +397,7 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
output += f"The {module_name} module has the following roles:\n\n"
for role_name in module_info["roles"]:
for role_name, _ in module_info["roles"].items():
output += f"- {role_name}\n"
for role_name, role_filename in module_info["roles"].items():
@@ -372,8 +417,35 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
of.write(output)
def build_option_card(module_name: str, frontmatter: Frontmatter) -> str:
"""
Build the overview index card for each reference target option.
"""
def indent_all(text: str, indent_size: int = 4) -> str:
"""
Indent all lines in a string.
"""
indent = " " * indent_size
lines = text.split("\n")
indented_text = indent + ("\n" + indent).join(lines)
return indented_text
def to_md_li(module_name: str, frontmatter: Frontmatter) -> str:
md_li = (
f"""- **[{module_name}](./{"-".join(module_name.split(" "))}.md)**\n\n"""
)
md_li += f"""{indent_all("---", 4)}\n\n"""
fmd = f"\n{frontmatter.description.strip()}" if frontmatter.description else ""
md_li += f"""{indent_all(fmd, 4)}"""
return md_li
return f"{to_md_li(module_name, frontmatter)}\n\n"
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.
"""
Split the flat dictionary of options into a dict of which each entry will construct complete option trees.
{
"a": { Data }
"a.b": { Data }
@@ -457,7 +529,9 @@ def option_short_name(option_name: str) -> str:
def options_to_tree(options: dict[str, Any], debug: bool = False) -> Option:
"""Convert the options dictionary to a tree structure."""
"""
Convert the options dictionary to a tree structure.
"""
# Helper function to create nested structure
def add_to_tree(path_parts: list[str], info: Any, current_node: Option) -> None:
@@ -509,24 +583,22 @@ def options_to_tree(options: dict[str, Any], debug: bool = False) -> Option:
def options_docs_from_tree(
root: Option,
init_level: int = 1,
prefix: list[str] | None = None,
root: Option, init_level: int = 1, prefix: list[str] | None = None
) -> str:
"""Eender the options from the tree structure.
"""
eender the options from the tree structure.
Args:
root (Option): The root option node.
init_level (int): The initial level of indentation.
prefix (list str): Will be printed as common prefix of all attribute names.
"""
def render_tree(option: Option, level: int = init_level) -> str:
output = ""
should_render = not option.name.startswith("<") and not option.name.startswith(
"_",
"_"
)
if should_render:
# short_name = option_short_name(option.name)
@@ -551,7 +623,7 @@ def options_docs_from_tree(
return md
if __name__ == "__main__":
if __name__ == "__main__": #
produce_clan_core_docs()
produce_clan_service_author_docs()

View File

@@ -1,33 +1,15 @@
# Auto-included Files
Clan automatically imports specific files from each machine directory and registers them, reducing the need for manual configuration.
Clan automatically imports the following files from a directory and registers them.
## Machine Registration
## Machine registration
Every folder under `machines/{machineName}` is automatically registered as a Clan machine.
Every folder `machines/{machineName}` will be registered automatically as a Clan machine.
!!! info "Files loaded automatically for each machine"
!!! info "Automatically loaded files"
The following files are detected and imported for every Clan machine:
The following files are loaded automatically for each Clan machine:
- [x] `machines/{machineName}/configuration.nix`
Main configuration file for the machine.
- [x] `machines/{machineName}/hardware-configuration.nix`
Hardware-specific configuration generated by NixOS.
- [x] `machines/{machineName}/facter.json`
Contains system facts. Automatically generated — see [nixos-facter](https://clan.lol/blog/nixos-facter/) for details.
- [x] `machines/{machineName}/disko.nix`
Disk layout configuration. See the [disko quickstart](https://github.com/nix-community/disko/blob/master/docs/quickstart.md) for more info.
## Other Auto-included Files
* **`inventory.json`**
Managed by Clan's API.
Merges with `clan.inventory` to extend the inventory.
* **`.clan-flake`**
Sentinel file to be used to locate the root of a Clan repository.
Falls back to `.git`, `.hg`, `.svn`, or `flake.nix` if not found.
- [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,6 +1,6 @@
# Using `clanServices`
Clan's `clanServices` system is a composable way to define and deploy services across machines.
Clans `clanServices` system is a composable way to define and deploy services across machines.
This guide shows how to **instantiate** a `clanService`, explains how service definitions are structured in your inventory, and how to pick or create services from modules exposed by flakes.
@@ -130,7 +130,7 @@ inventory.instances = {
## Picking a clanService
You can use services exposed by Clan's core module library, `clan-core`.
You can use services exposed by Clans core module library, `clan-core`.
🔗 See: [List of Available Services in clan-core](../reference/clanServices/index.md)
@@ -152,7 +152,7 @@ You might expose your service module from your flake — this makes it easy for
---
## What's Next?
## Whats Next?
* [Author your own clanService →](../guides/services/community.md)
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.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) 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](../options.md) available within `mkFlake`.
```nix
{

View File

@@ -271,7 +271,7 @@ The following table shows the migration status of each deprecated clanModule:
| `nginx` | ❌ Removed | |
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
| `postgresql` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | See [migration guide](../../reference/clanServices/users.md#migration-from-root-password-module) |
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | |
| `single-disk` | ❌ Removed | |
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
| `state-version` | ✅ [Migrated](../../reference/clanServices/state-version.md) | |

View File

@@ -1,184 +0,0 @@
# Connecting to Your Machines
Clan provides automatic networking with fallback mechanisms to reliably connect to your machines.
## Option 1: Automatic Networking with Fallback (Recommended)
Clan's networking module automatically manages connections through various network technologies with intelligent fallback. When you run `clan ssh` or `clan machines update`, Clan tries each configured network by priority until one succeeds.
### Basic Setup with Internet Service
For machines with public IPs or DNS names, use the `internet` service to configure direct SSH while keeping fallback options:
```{.nix title="flake.nix" hl_lines="7-10 14-16"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.instances = {
# Direct SSH with fallback support
internet = {
roles.default.machines.server1 = {
settings.address = "server1.example.com";
};
roles.default.machines.server2 = {
settings.address = "192.168.1.100";
};
};
# Fallback: Secure connections via Tor
tor = {
roles.server.tags.nixos = { };
};
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
### Advanced Setup with Multiple Networks
```{.nix title="flake.nix" hl_lines="7-10 13-16 19-21"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.instances = {
# Priority 1: Try direct connection first
internet = {
roles.default.machines.publicserver = {
settings.address = "public.example.com";
};
};
# Priority 2: VPN for internal machines
zerotier = {
roles.controller.machines."controller" = { };
roles.peer.tags.nixos = { };
};
# Priority 3: Tor as universal fallback
tor = {
roles.server.tags.nixos = { };
};
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
### How It Works
Clan automatically tries networks in order of priority:
1. Direct internet connections (if configured)
2. VPN networks (ZeroTier, Tailscale, etc.)
3. Tor hidden services
4. Any other configured networks
If one network fails, Clan automatically tries the next.
### Useful Commands
```bash
# View all configured networks and their status
clan network list
# Test connectivity through all networks
clan network ping machine1
# Show complete network topology
clan network overview
```
## Option 2: Manual targetHost (Bypasses Fallback!)
!!! warning
Setting `targetHost` directly **disables all automatic networking and fallback**. Only use this if you need complete control and don't want Clan's intelligent connection management.
### Using Inventory (For Static Addresses)
Use inventory-level `targetHost` when the address is **static** and doesn't depend on NixOS configuration:
```{.nix title="flake.nix" hl_lines="8"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.machines.server = {
# WARNING: This bypasses all networking modules!
# Use for: Static IPs, DNS names, known hostnames
deploy.targetHost = "root@192.168.1.100";
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
**When to use inventory-level:**
- Static IP addresses: `"root@192.168.1.100"`
- DNS names: `"user@server.example.com"`
- Any address that doesn't change based on machine configuration
### Using NixOS Configuration (For Dynamic Addresses)
Use machine-level `targetHost` when you need to **interpolate values from the NixOS configuration**:
```{.nix title="flake.nix" hl_lines="7"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
machines.server = { config, ... }: {
# WARNING: This also bypasses all networking modules!
# REQUIRED for: Addresses that depend on NixOS config
clan.core.networking.targetHost = "root@${config.networking.hostName}.local";
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
**When to use machine-level (NixOS config):**
- Using hostName from config: `"root@${config.networking.hostName}.local"`
- Building from multiple config values: `"${config.users.users.deploy.name}@${config.networking.hostName}"`
- Any address that depends on evaluated NixOS configuration
!!! info "Key Difference"
**Inventory-level** (`deploy.targetHost`) is evaluated immediately and works with static strings.
**Machine-level** (`clan.core.networking.targetHost`) is evaluated after NixOS configuration and can access `config.*` values.
## Quick Decision Guide
| Scenario | Recommended Approach | Why |
|----------|---------------------|-----|
| Public servers | `internet` service | Keeps fallback options |
| Mixed infrastructure | Multiple networks | Automatic failover |
| Machines behind NAT | ZeroTier/Tor | NAT traversal with fallback |
| Testing/debugging | Manual targetHost | Full control, no magic |
| Single static machine | Manual targetHost | Simple, no overhead |
## Command-Line Override
The `--target-host` flag bypasses ALL networking configuration:
```bash
# Emergency access - ignores all networking config
clan machines update server --target-host root@backup-ip.com
# Direct SSH - no fallback attempted
clan ssh laptop --target-host user@10.0.0.5
```
Use this for debugging or emergency access when automatic networking isn't working.

View File

@@ -0,0 +1,84 @@
# How to Set `targetHost` for a Machine
The `targetHost` defines where the machine can be reached for operations like SSH or deployment. You can set it in two ways, depending on your use case.
---
## ✅ Option 1: Use the Inventory (Recommended for Static Hosts)
If the hostname is **static**, like `server.example.com`, set it in the **inventory**:
```{.nix title="flake.nix" hl_lines="8"}
{
# edlided
outputs =
{ self, clan-core, ... }:
let
# Sometimes this attribute set is defined in clan.nix
clan = clan-core.lib.clan {
inventory.machines.jon = {
deploy.targetHost = "root@server.example.com";
};
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
# elided
};
}
```
This is fast, simple and explicit, and doesnt require evaluating the NixOS config. We can also displayed it in the clan-cli or clan-app.
---
## ✅ Option 2: Use NixOS (Only for Dynamic Hosts)
If your target host depends on a **dynamic expression** (like using the machines evaluated FQDN), set it inside the NixOS module:
```{.nix title="flake.nix" hl_lines="8"}
{
# edlided
outputs =
{ self, clan-core, ... }:
let
# Sometimes this attribute set is defined in clan.nix
clan = clan-core.lib.clan {
machines.jon = {config, ...}: {
clan.core.networking.targetHost = "jon@${config.networking.fqdn}";
};
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
# elided
};
}
```
Use this **only if the value cannot be made static**, because its slower and won't be displayed in the clan-cli or clan-app yet.
---
## 📝 TL;DR
| Use Case | Use Inventory? | Example |
| ------------------------- | -------------- | -------------------------------- |
| Static hostname | ✅ Yes | `root@server.example.com` |
| Dynamic config expression | ❌ No | `jon@${config.networking.fqdn}` |
---
## 🚀 Coming Soon: Unified Networking Module
Were working on a new networking module that will automatically do all of this for you.
- Easier to use
- Sane defaults: Youll always be able to reach the machine — no need to worry about hostnames.
- ✨ Migration from **either method** will be supported and simple.
## Summary
- Ask: *Does this hostname dynamically change based on NixOS config?*
- If **no**, use the inventory.
- If **yes**, then use NixOS config.

6
docs/site/options.md Normal file
View File

@@ -0,0 +1,6 @@
---
template: options.html
---
<iframe src="/options-page/" height="1000" width="100%"></iframe>

View File

@@ -4,7 +4,7 @@ This section of the site provides an overview of available options and commands
---
- [Clan Configuration Option](/options) - for defining a Clan
- [Clan Configuration Option](../options.md) - for defining a Clan
- Learn how to use the [Clan CLI](./cli/index.md)
- Explore available [services](./clanServices/index.md)
- [NixOS Configuration Options](./clan.core/index.md) - Additional options avilable on a NixOS machine.

28
flake.lock generated
View File

@@ -13,11 +13,11 @@
]
},
"locked": {
"lastModified": 1756091210,
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
"lastModified": 1753067306,
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/eb831bca21476fa8f6df26cb39e076842634700d.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/18dfd42bdb2cfff510b8c74206005f733e38d8b9.tar.gz"
},
"original": {
"type": "tarball",
@@ -71,11 +71,11 @@
]
},
"locked": {
"lastModified": 1755825449,
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
"lastModified": 1755275010,
"narHash": "sha256-lEApCoWUEWh0Ifc3k1JdVjpMtFFXeL2gG1qvBnoRc2I=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
"rev": "7220b01d679e93ede8d7b25d6f392855b81dd475",
"type": "github"
},
"original": {
@@ -86,11 +86,11 @@
},
"nix-select": {
"locked": {
"lastModified": 1755887746,
"narHash": "sha256-lzWbpHKX0WAn/jJDoCijIDss3rqYIPawe46GDaE6U3g=",
"rev": "92c2574c5e113281591be01e89bb9ddb31d19156",
"lastModified": 1745005516,
"narHash": "sha256-IVaoOGDIvAa/8I0sdiiZuKptDldrkDWUNf/+ezIRhyc=",
"rev": "69d8bf596194c5c35a4e90dd02c52aa530caddf8",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/92c2574c5e113281591be01e89bb9ddb31d19156.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/69d8bf596194c5c35a4e90dd02c52aa530caddf8.tar.gz"
},
"original": {
"type": "tarball",
@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1755934250,
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"lastModified": 1754847726,
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
"type": "github"
},
"original": {

View File

@@ -328,7 +328,7 @@ rec {
# To get the type of a Deferred modules we need to know the interface of the place where it is evaluated.
# i.e. in case of a clan.service this is the interface of the service which dynamically changes depending on the service
# We assign "type" = []
# This means any value is valid — or like TypeScript's unknown.
# This means any value is valid — or like TypeScripts unknown.
# We can assign the type later, when we know the exact interface.
# tsType = "unknown" is a type that we preload for json2ts, such that it gets the correct type in typescript
(option.type.name == "deferredModule")

View File

@@ -32,15 +32,11 @@ def init_test_environment() -> None:
# Set up network bridge
subprocess.run(
["ip", "link", "add", "br0", "type", "bridge"],
check=True,
text=True,
["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,
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"], check=True, text=True
)
# Set up minimal passwd file for unprivileged operations
@@ -115,7 +111,8 @@ def mount(
mountflags: int = 0,
data: str | None = None,
) -> None:
"""A Python wrapper for the mount system call.
"""
A Python wrapper for the mount system call.
:param source: The source of the file system (e.g., device name, remote filesystem).
:param target: The mount point (an existing directory).
@@ -132,11 +129,7 @@ def mount(
# Call the mount system call
result = libc.mount(
source_c,
target_c,
fstype_c,
ctypes.c_ulong(mountflags),
data_c,
source_c, target_c, fstype_c, ctypes.c_ulong(mountflags), data_c
)
if result != 0:
@@ -152,7 +145,7 @@ def prepare_machine_root(machinename: str, root: Path) -> None:
root.mkdir(parents=True, exist_ok=True)
root.joinpath("etc").mkdir(parents=True, exist_ok=True)
root.joinpath(".env").write_text(
"\n".join(f"{k}={v}" for k, v in os.environ.items()),
"\n".join(f"{k}={v}" for k, v in os.environ.items())
)
@@ -164,6 +157,7 @@ def retry(fn: Callable, timeout: int = 900) -> None:
"""Call the given function repeatedly, with 1 second intervals,
until it returns True or a timeout is reached.
"""
for _ in range(timeout):
if fn(False):
return
@@ -290,7 +284,8 @@ class Machine:
check_output: bool = True,
timeout: int | None = 900,
) -> subprocess.CompletedProcess:
"""Execute a shell command, returning a list `(status, stdout)`.
"""
Execute a shell command, returning a list `(status, stdout)`.
Commands are run with `set -euo pipefail` set:
@@ -321,6 +316,7 @@ class Machine:
`timeout` parameter, e.g., `execute(cmd, timeout=10)` or
`execute(cmd, timeout=None)`. The default is 900 seconds.
"""
# Always run command with shell opts
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
@@ -334,9 +330,7 @@ class Machine:
return proc
def nested(
self,
msg: str,
attrs: dict[str, str] | None = None,
self, msg: str, attrs: dict[str, str] | None = None
) -> _GeneratorContextManager:
if attrs is None:
attrs = {}
@@ -345,7 +339,8 @@ class Machine:
return self.logger.nested(msg, my_attrs)
def systemctl(self, q: str) -> subprocess.CompletedProcess:
"""Runs `systemctl` commands with optional support for
"""
Runs `systemctl` commands with optional support for
`systemctl --user`
```py
@@ -360,7 +355,8 @@ class Machine:
return self.execute(f"systemctl {q}")
def wait_until_succeeds(self, command: str, timeout: int = 900) -> str:
"""Repeat a shell command with 1-second intervals until it succeeds.
"""
Repeat a shell command with 1-second intervals until it succeeds.
Has a default timeout of 900 seconds which can be modified, e.g.
`wait_until_succeeds(cmd, timeout=10)`. See `execute` for details on
command execution.
@@ -378,17 +374,18 @@ class Machine:
return output
def wait_for_open_port(
self,
port: int,
addr: str = "localhost",
timeout: int = 900,
self, port: int, addr: str = "localhost", timeout: int = 900
) -> None:
"""Wait for a port to be open on the given address."""
"""
Wait for a port to be open on the given address.
"""
command = f"nc -z {shlex.quote(addr)} {port}"
self.wait_until_succeeds(command, timeout=timeout)
def wait_for_file(self, filename: str, timeout: int = 30) -> None:
"""Waits until the file exists in the machine's file system."""
"""
Waits until the file exists in the machine's file system.
"""
def check_file(_last_try: bool) -> bool:
result = self.execute(f"test -e {filename}")
@@ -398,7 +395,8 @@ class Machine:
retry(check_file, timeout)
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
"""Wait for a systemd unit to get into "active" state.
"""
Wait for a systemd unit to get into "active" state.
Throws exceptions on "failed" and "inactive" states as well as after
timing out.
"""
@@ -443,7 +441,9 @@ class Machine:
return res.stdout
def shutdown(self) -> None:
"""Shut down the machine, waiting for the VM to exit."""
"""
Shut down the machine, waiting for the VM to exit.
"""
if self.process:
self.process.terminate()
self.process.wait()
@@ -557,7 +557,7 @@ class Driver:
rootdir=tempdir_path / container.name,
out_dir=self.out_dir,
logger=self.logger,
),
)
)
def start_all(self) -> None:
@@ -581,7 +581,7 @@ class Driver:
)
print(
f"To attach to container {machine.name} run on the same machine that runs the test:",
f"To attach to container {machine.name} run on the same machine that runs the test:"
)
print(
" ".join(
@@ -603,8 +603,8 @@ class Driver:
"-c",
"bash",
Style.RESET_ALL,
],
),
]
)
)
def test_symbols(self) -> dict[str, Any]:
@@ -623,7 +623,7 @@ class Driver:
"additionally exposed symbols:\n "
+ ", ".join(m.name for m in self.machines)
+ ",\n "
+ ", ".join(list(general_symbols.keys())),
+ ", ".join(list(general_symbols.keys()))
)
return {**general_symbols, **machine_symbols}

View File

@@ -25,18 +25,14 @@ class AbstractLogger(ABC):
@abstractmethod
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@abstractmethod
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@@ -70,7 +66,7 @@ class JunitXMLLogger(AbstractLogger):
def __init__(self, outfile: Path) -> None:
self.tests: dict[str, JunitXMLLogger.TestCaseState] = {
"main": self.TestCaseState(),
"main": self.TestCaseState()
}
self.currentSubtest = "main"
self.outfile: Path = outfile
@@ -82,9 +78,7 @@ class JunitXMLLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
old_test = self.currentSubtest
self.tests.setdefault(name, self.TestCaseState())
@@ -96,9 +90,7 @@ class JunitXMLLogger(AbstractLogger):
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
self.log(message)
yield
@@ -152,9 +144,7 @@ class CompositeLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
@@ -163,9 +153,7 @@ class CompositeLogger(AbstractLogger):
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
@@ -212,24 +200,19 @@ class TerminalLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
self._eprint(
self.maybe_prefix(
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL,
attributes,
),
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes
)
)
tic = time.time()
@@ -276,9 +259,7 @@ class XMLLogger(AbstractLogger):
return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
def maybe_prefix(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> str:
if attributes and "machine" in attributes:
return f"{attributes['machine']}: {message}"
@@ -328,18 +309,14 @@ class XMLLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
if attributes is None:
attributes = {}

View File

@@ -1,17 +1,40 @@
{ ... }:
{
perSystem.clan.nixosTests.machine-id = {
perSystem =
{ ... }:
{
clan.nixosTests.machine-id = {
name = "service-machine-id";
name = "service-machine-id";
clan = {
directory = ./.;
machines.server = {
clan.core.settings.machine-id.enable = true;
clan = {
directory = ./.;
# Workaround until we can use nodes.server = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.server = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
# Test machine ID generation
clan.core.settings.machine-id.enable = true;
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.server = { };
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";
};
};
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";
};
}

View File

@@ -10,14 +10,30 @@
clan = {
directory = ./.;
machines.machine = {
clan.core.postgresql.enable = true;
clan.core.postgresql.users.test = { };
clan.core.postgresql.databases.test.create.options.OWNER = "test";
clan.core.settings.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 = [
{
clan.core.postgresql.enable = true;
clan.core.postgresql.users.test = { };
clan.core.postgresql.databases.test.create.options.OWNER = "test";
clan.core.settings.directory = ./.;
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.machine = { };
testScript =
let
runpg = "runuser -u postgres -- /run/current-system/sw/bin/psql";

View File

@@ -304,15 +304,6 @@ in
description = "The unix file mode of the file. Must be a 4-digit octal number.";
default = "0400";
};
exists = mkOption {
description = ''
Returns true if the file exists, This is used to guard against reading not set value in evaluation.
This currently only works for non secret files.
'';
type = bool;
default = if file.config.secret then throw "Cannot determine existance of secret file" else false;
defaultText = "Throws error because the existance of a secret file cannot be determined";
};
value =
mkOption {
description = ''

View File

@@ -25,7 +25,7 @@ in
);
value = mkIf (file.config.secret == false) (
# dynamically adjust priority to allow overriding with mkDefault in case the file is not found
if file.config.exists then
if (pathExists file.config.flakePath) then
# if the file is found it should have normal priority
readFile file.config.flakePath
else
@@ -34,7 +34,6 @@ in
throw "Please run `clan vars generate ${config.clan.core.settings.machine.name}` as file was not found: ${file.config.path}"
)
);
exists = mkIf (file.config.secret == false) (pathExists file.config.flakePath);
};
};
}

View File

@@ -195,7 +195,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
(node_id >> 16) & 0xFF,
(node_id >> 8) & 0xFF,
(node_id) & 0xFF,
],
]
)
return ipaddress.IPv6Address(bytes(addr_parts))
@@ -203,10 +203,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--mode",
choices=["network", "identity"],
required=True,
type=str,
"--mode", choices=["network", "identity"], required=True, type=str
)
parser.add_argument("--ip", type=Path, required=True)
parser.add_argument("--identity-secret", type=Path, required=True)

View File

@@ -17,7 +17,7 @@ def main() -> None:
moon_json = json.loads(Path(moon_json_path).read_text())
moon_json["roots"][0]["stableEndpoints"] = json.loads(
Path(endpoint_config).read_text(),
Path(endpoint_config).read_text()
)
with NamedTemporaryFile("w") as f:

View File

@@ -38,7 +38,8 @@ def get_gitea_api_url(remote: str = "origin") -> str:
host_and_path = remote_url.split("@")[1] # git.clan.lol:clan/clan-core.git
host = host_and_path.split(":")[0] # git.clan.lol
repo_path = host_and_path.split(":")[1] # clan/clan-core.git
repo_path = repo_path.removesuffix(".git") # clan/clan-core
if repo_path.endswith(".git"):
repo_path = repo_path[:-4] # clan/clan-core
elif remote_url.startswith("https://"):
# HTTPS format: https://git.clan.lol/clan/clan-core.git
url_parts = remote_url.replace("https://", "").split("/")
@@ -85,10 +86,7 @@ def get_repo_info_from_api_url(api_url: str) -> tuple[str, str]:
def fetch_pr_statuses(
repo_owner: str,
repo_name: str,
commit_sha: str,
host: str,
repo_owner: str, repo_name: str, commit_sha: str, host: str
) -> list[dict]:
"""Fetch CI statuses for a specific commit SHA."""
status_url = (
@@ -185,7 +183,7 @@ def run_git_command(command: list) -> tuple[int, str, str]:
def get_current_branch_name() -> str:
exit_code, branch_name, error = run_git_command(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"]
)
if exit_code != 0:
@@ -198,7 +196,7 @@ def get_current_branch_name() -> str:
def get_latest_commit_info() -> tuple[str, str]:
"""Get the title and body of the latest commit."""
exit_code, commit_msg, error = run_git_command(
["git", "log", "-1", "--pretty=format:%B"],
["git", "log", "-1", "--pretty=format:%B"]
)
if exit_code != 0:
@@ -227,7 +225,7 @@ def get_commits_since_main() -> list[tuple[str, str]]:
"main..HEAD",
"--no-merges",
"--pretty=format:%s|%b|---END---",
],
]
)
if exit_code != 0:
@@ -265,9 +263,7 @@ def open_editor_for_pr() -> tuple[str, str]:
commits_since_main = get_commits_since_main()
with tempfile.NamedTemporaryFile(
mode="w+",
suffix="COMMIT_EDITMSG",
delete=False,
mode="w+", suffix="COMMIT_EDITMSG", delete=False
) as temp_file:
temp_file.flush()
temp_file_path = temp_file.name
@@ -284,7 +280,7 @@ def open_editor_for_pr() -> tuple[str, str]:
temp_file.write("# The first line will be used as the PR title.\n")
temp_file.write("# Everything else will be used as the PR description.\n")
temp_file.write(
"# To abort creation of the PR, close editor with an error code.\n",
"# To abort creation of the PR, close editor with an error code.\n"
)
temp_file.write("# In vim for example you can use :cq!\n")
temp_file.write("#\n")
@@ -377,7 +373,7 @@ def create_agit_push(
print(
f" Description: {description[:50]}..."
if len(description) > 50
else f" Description: {description}",
else f" Description: {description}"
)
print()
@@ -534,26 +530,19 @@ Examples:
)
create_parser.add_argument(
"-t",
"--topic",
help="Set PR topic (default: current branch name)",
"-t", "--topic", help="Set PR topic (default: current branch name)"
)
create_parser.add_argument(
"--title",
help="Set the PR title (default: last commit title)",
"--title", help="Set the PR title (default: last commit title)"
)
create_parser.add_argument(
"--description",
help="Override the PR description (default: commit body)",
"--description", help="Override the PR description (default: commit body)"
)
create_parser.add_argument(
"-f",
"--force",
action="store_true",
help="Force push the changes",
"-f", "--force", action="store_true", help="Force push the changes"
)
create_parser.add_argument(

View File

@@ -13,9 +13,7 @@ log = logging.getLogger(__name__)
def main(argv: list[str] = sys.argv) -> int:
parser = argparse.ArgumentParser(description="Clan App")
parser.add_argument(
"--content-uri",
type=str,
help="The URI of the content to display",
"--content-uri", type=str, help="The URI of the content to display"
)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument(

View File

@@ -56,23 +56,18 @@ class ApiBridge(ABC):
for middleware in self.middleware_chain:
try:
log.debug(
f"{middleware.__class__.__name__} => {request.method_name}",
f"{middleware.__class__.__name__} => {request.method_name}"
)
middleware.process(context)
except Exception as e:
# If middleware fails, handle error
self.send_api_error_response(
request.op_key or "unknown",
str(e),
["middleware_error"],
request.op_key or "unknown", str(e), ["middleware_error"]
)
return
def send_api_error_response(
self,
op_key: str,
error_message: str,
location: list[str],
self, op_key: str, error_message: str, location: list[str]
) -> None:
"""Send an error response."""
from clan_lib.api import ApiError, ErrorDataClass
@@ -85,7 +80,7 @@ class ApiBridge(ABC):
message="An internal error occured",
description=error_message,
location=location,
),
)
],
)
@@ -112,7 +107,6 @@ class ApiBridge(ABC):
thread_name: Name for the thread (for debugging)
wait_for_completion: Whether to wait for the thread to complete
timeout: Timeout in seconds when waiting for completion
"""
op_key = request.op_key or "unknown"
@@ -122,7 +116,7 @@ class ApiBridge(ABC):
try:
log.debug(
f"Processing {request.method_name} with args {request.args} "
f"and header {request.header} in thread {thread_name}",
f"and header {request.header} in thread {thread_name}"
)
self.process_request(request)
finally:
@@ -130,9 +124,7 @@ class ApiBridge(ABC):
stop_event = threading.Event()
thread = threading.Thread(
target=thread_task,
args=(stop_event,),
name=thread_name,
target=thread_task, args=(stop_event,), name=thread_name
)
thread.start()
@@ -146,7 +138,5 @@ class ApiBridge(ABC):
if thread.is_alive():
stop_event.set() # Cancel the thread
self.send_api_error_response(
op_key,
"Request timeout",
["api_bridge", request.method_name],
op_key, "Request timeout", ["api_bridge", request.method_name]
)

View File

@@ -26,7 +26,8 @@ RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {}
def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
"""Opens the clan folder using the GTK file dialog.
"""
Opens the clan folder using the GTK file dialog.
Returns the path to the clan folder or an error if it fails.
"""
file_request = FileRequest(
@@ -51,7 +52,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
message="No folder selected",
description="You must select a folder to open.",
location=["get_clan_folder"],
),
)
],
)
@@ -65,7 +66,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
message="Invalid clan folder",
description=f"The selected folder '{clan_folder}' is not a valid clan folder.",
location=["get_clan_folder"],
),
)
],
)
@@ -101,10 +102,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
except Exception as e:
log.exception("Error opening file")
@@ -117,9 +116,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_file_select_multiple(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -129,10 +128,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_paths = remove_none([gfile.get_path() for gfile in gfiles])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_paths,
status="success",
),
op_key=op_key, data=selected_paths, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -147,9 +144,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -159,10 +156,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -177,9 +172,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_save_finish(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -189,10 +184,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -207,9 +200,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
dialog = Gtk.FileDialog()

View File

@@ -39,7 +39,7 @@ class ArgumentParsingMiddleware(Middleware):
except Exception as e:
log.exception(
f"Error while parsing arguments for {context.request.method_name}",
f"Error while parsing arguments for {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",

View File

@@ -23,9 +23,7 @@ class Middleware(ABC):
"""Process the request through this middleware."""
def register_context_manager(
self,
context: MiddlewareContext,
cm: AbstractContextManager[Any],
self, context: MiddlewareContext, cm: AbstractContextManager[Any]
) -> Any:
"""Register a context manager with the exit stack."""
return context.exit_stack.enter_context(cm)

View File

@@ -25,26 +25,23 @@ class LoggingMiddleware(Middleware):
try:
# Handle log group configuration
log_group: list[str] | None = context.request.header.get("logging", {}).get(
"group_path",
None,
"group_path", None
)
if log_group is not None:
if not isinstance(log_group, list):
msg = f"Expected log_group to be a list, got {type(log_group)}"
raise TypeError(msg) # noqa: TRY301
log.warning(
f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}",
f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}"
)
# Create log file
log_file = self.log_manager.create_log_file(
method,
op_key=context.request.op_key or "unknown",
group_path=log_group,
method, op_key=context.request.op_key or "unknown", group_path=log_group
).get_file_path()
except Exception as e:
log.exception(
f"Error while handling request header of {context.request.method_name}",
f"Error while handling request header of {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",
@@ -79,8 +76,7 @@ class LoggingMiddleware(Middleware):
line_buffering=True,
)
self.handler = setup_logging(
log.getEffectiveLevel(),
log_file=handler_stream,
log.getEffectiveLevel(), log_file=handler_stream
)
return self

View File

@@ -32,7 +32,7 @@ class MethodExecutionMiddleware(Middleware):
except Exception as e:
log.exception(
f"Error while handling result of {context.request.method_name}",
f"Error while handling result of {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",

View File

@@ -48,7 +48,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
# Add a log group ["clans", <dynamic_name>, "machines", <dynamic_name>]
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
clan_log_group = LogGroupConfig("clans", "Clans").add_child(
LogGroupConfig("machines", "Machines"),
LogGroupConfig("machines", "Machines")
)
log_manager = log_manager.add_root_group_config(clan_log_group)
# Init LogManager global in log_manager_api module
@@ -89,7 +89,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
# HTTP-only mode - keep the server running
log.info("HTTP API server running...")
log.info(
f"Swagger: http://{app_opts.http_host}:{app_opts.http_port}/api/swagger",
f"Swagger: http://{app_opts.http_host}:{app_opts.http_port}/api/swagger"
)
log.info("Press Ctrl+C to stop the server")

View File

@@ -63,9 +63,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _send_json_response_with_status(
self,
data: dict[str, Any],
status_code: int = 200,
self, data: dict[str, Any], status_code: int = 200
) -> None:
"""Send a JSON response with the given status code."""
try:
@@ -84,13 +82,11 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
response_dict = dataclass_to_dict(response)
self._send_json_response_with_status(response_dict, 200)
log.debug(
f"HTTP response for {response._op_key}: {json.dumps(response_dict, indent=2)}", # noqa: SLF001
f"HTTP response for {response._op_key}: {json.dumps(response_dict, indent=2)}" # noqa: SLF001
)
def _create_success_response(
self,
op_key: str,
data: dict[str, Any],
self, op_key: str, data: dict[str, Any]
) -> BackendResponse:
"""Create a successful API response."""
return BackendResponse(
@@ -102,16 +98,14 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
def _send_info_response(self) -> None:
"""Send server information response."""
response = self._create_success_response(
"info",
{"message": "Clan API Server", "version": "1.0.0"},
"info", {"message": "Clan API Server", "version": "1.0.0"}
)
self.send_api_response(response)
def _send_methods_response(self) -> None:
"""Send available API methods response."""
response = self._create_success_response(
"methods",
{"methods": list(self.api.functions.keys())},
"methods", {"methods": list(self.api.functions.keys())}
)
self.send_api_response(response)
@@ -185,7 +179,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
json_data = json.loads(file_data.decode("utf-8"))
server_address = getattr(self.server, "server_address", ("localhost", 80))
json_data["servers"] = [
{"url": f"http://{server_address[0]}:{server_address[1]}/api/v1/"},
{"url": f"http://{server_address[0]}:{server_address[1]}/api/v1/"}
]
file_data = json.dumps(json_data, indent=2).encode("utf-8")
@@ -219,9 +213,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
# Validate API path
if not path.startswith("/api/v1/"):
self.send_api_error_response(
"post",
f"Path not found: {path}",
["http_bridge", "POST"],
"post", f"Path not found: {path}", ["http_bridge", "POST"]
)
return
@@ -229,9 +221,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
method_name = path[len("/api/v1/") :]
if not method_name:
self.send_api_error_response(
"post",
"Method name required",
["http_bridge", "POST"],
"post", "Method name required", ["http_bridge", "POST"]
)
return
@@ -299,26 +289,19 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
# Create API request
api_request = BackendRequest(
method_name=method_name,
args=body,
header=header,
op_key=op_key,
method_name=method_name, args=body, header=header, op_key=op_key
)
except Exception as e:
self.send_api_error_response(
gen_op_key,
str(e),
["http_bridge", method_name],
gen_op_key, str(e), ["http_bridge", method_name]
)
return
self._process_api_request_in_thread(api_request, method_name)
def _parse_request_data(
self,
request_data: dict[str, Any],
gen_op_key: str,
self, request_data: dict[str, Any], gen_op_key: str
) -> tuple[dict[str, Any], dict[str, Any], str]:
"""Parse and validate request data components."""
header = request_data.get("header", {})
@@ -361,9 +344,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
pass
def _process_api_request_in_thread(
self,
api_request: BackendRequest,
method_name: str,
self, api_request: BackendRequest, method_name: str
) -> None:
"""Process the API request in a separate thread."""
stop_event = threading.Event()
@@ -377,7 +358,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
log.debug(
f"Processing {request.method_name} with args {request.args} "
f"and header {request.header}",
f"and header {request.header}"
)
self.process_request(request)

View File

@@ -64,8 +64,7 @@ def mock_log_manager() -> Mock:
@pytest.fixture
def http_bridge(
mock_api: MethodRegistry,
mock_log_manager: Mock,
mock_api: MethodRegistry, mock_log_manager: Mock
) -> tuple[MethodRegistry, tuple]:
"""Create HTTP bridge dependencies for testing."""
middleware_chain = (
@@ -257,9 +256,7 @@ class TestIntegration:
"""Integration tests for HTTP API components."""
def test_full_request_flow(
self,
mock_api: MethodRegistry,
mock_log_manager: Mock,
self, mock_api: MethodRegistry, mock_log_manager: Mock
) -> None:
"""Test complete request flow from server to bridge to middleware."""
server: HttpApiServer = HttpApiServer(
@@ -304,9 +301,7 @@ class TestIntegration:
server.stop()
def test_blocking_task(
self,
mock_api: MethodRegistry,
mock_log_manager: Mock,
self, mock_api: MethodRegistry, mock_log_manager: Mock
) -> None:
shared_threads: dict[str, tasks.WebThread] = {}
tasks.BAKEND_THREADS = shared_threads

View File

@@ -21,7 +21,7 @@ def _get_lib_names() -> list[str]:
machine = platform.machine().lower()
if system == "windows":
if machine in {"amd64", "x86_64"}:
if machine == "amd64" or machine == "x86_64":
return ["webview.dll", "WebView2Loader.dll"]
if machine == "arm64":
msg = "arm64 is not supported on Windows"
@@ -36,6 +36,7 @@ def _get_lib_names() -> list[str]:
def _be_sure_libraries() -> list[Path] | None:
"""Ensure libraries exist and return paths."""
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
if not lib_dir:
msg = "WEBVIEW_LIB_DIR environment variable is not set"

View File

@@ -144,9 +144,7 @@ class Webview:
)
else:
bridge = WebviewBridge(
webview=self,
middleware_chain=tuple(self._middleware),
threads={},
webview=self, middleware_chain=tuple(self._middleware), threads={}
)
self._bridge = bridge
@@ -156,10 +154,7 @@ class Webview:
def set_size(self, value: Size) -> None:
"""Set the webview size (legacy compatibility)."""
_webview_lib.webview_set_size(
self.handle,
value.width,
value.height,
value.hint,
self.handle, value.width, value.height, value.hint
)
def set_title(self, value: str) -> None:
@@ -199,10 +194,7 @@ class Webview:
self._callbacks[name] = c_callback
_webview_lib.webview_bind(
self.handle,
_encode_c_string(name),
c_callback,
None,
self.handle, _encode_c_string(name), c_callback, None
)
def bind(self, name: str, callback: Callable[..., Any]) -> None:
@@ -227,10 +219,7 @@ class Webview:
def return_(self, seq: str, status: int, result: str) -> None:
_webview_lib.webview_return(
self.handle,
_encode_c_string(seq),
status,
_encode_c_string(result),
self.handle, _encode_c_string(seq), status, _encode_c_string(result)
)
def eval(self, source: str) -> None:

View File

@@ -26,9 +26,7 @@ class WebviewBridge(ApiBridge):
def send_api_response(self, response: BackendResponse) -> None:
"""Send response back to the webview client."""
serialized = json.dumps(
dataclass_to_dict(response),
indent=4,
ensure_ascii=False,
dataclass_to_dict(response), indent=4, ensure_ascii=False
)
log.debug(f"Sending response: {serialized}")
@@ -42,6 +40,7 @@ class WebviewBridge(ApiBridge):
arg: int,
) -> None:
"""Handle a call from webview's JavaScript bridge."""
try:
op_key = op_key_bytes.decode()
raw_args = json.loads(request_data.decode())
@@ -69,10 +68,7 @@ class WebviewBridge(ApiBridge):
# Create API request
api_request = BackendRequest(
method_name=method_name,
args=args,
header=header,
op_key=op_key,
method_name=method_name, args=args, header=header, op_key=op_key
)
except Exception as e:
@@ -81,9 +77,7 @@ class WebviewBridge(ApiBridge):
)
log.exception(msg)
self.send_api_error_response(
op_key,
str(e),
["webview_bridge", method_name],
op_key, str(e), ["webview_bridge", method_name]
)
return

View File

@@ -54,7 +54,8 @@ class Command:
@pytest.fixture
def command() -> Iterator[Command]:
"""Starts a background command. The process is automatically terminated in the end.
"""
Starts a background command. The process is automatically terminated in the end.
>>> p = command.run(["some", "daemon"])
>>> print(p.pid)
"""

View File

@@ -2,15 +2,12 @@ from __future__ import annotations
import logging
import subprocess
from typing import TYPE_CHECKING
from pathlib import Path
import pytest
from clan_lib.custom_logger import setup_logging
from clan_lib.nix import nix_shell
if TYPE_CHECKING:
from pathlib import Path
pytest_plugins = [
"temporary_dir",
"root",

View File

@@ -13,17 +13,23 @@ else:
@pytest.fixture(scope="session")
def project_root() -> Path:
"""Root directory the clan-cli"""
"""
Root directory the clan-cli
"""
return PROJECT_ROOT
@pytest.fixture(scope="session")
def test_root() -> Path:
"""Root directory of the tests"""
"""
Root directory of the tests
"""
return TEST_ROOT
@pytest.fixture(scope="session")
def clan_core() -> Path:
"""Directory of the clan-core flake"""
"""
Directory of the clan-core flake
"""
return CLAN_CORE

View File

@@ -24,11 +24,7 @@ def app() -> Generator[GtkProc]:
cmd = [sys.executable, "-m", "clan_app"]
print(f"Running: {cmd}")
rapp = Popen(
cmd,
text=True,
stdout=sys.stdout,
stderr=sys.stderr,
start_new_session=True,
cmd, text=True, stdout=sys.stdout, stderr=sys.stderr, start_new_session=True
)
yield GtkProc(rapp)
# Cleanup: Terminate your application

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M9.223 38.777h8.444V43H5V30.333h4.223zM43 43h-4.223v-8.444h-8.444V43h-4.222V21.889H43zM30.333 30.333h8.444v-4.222h-8.444zM17.667 9.223H9.223v4.221h8.444v4.223H9.223v4.222h8.444v4.222H5V5h12.667zm4.222 12.666h-4.222v-4.222h4.222zM43 17.667h-4.223V9.223h-8.444V5H43zm-21.111-4.223h-4.222V9.223h4.222z"/></svg>

Before

Width:  |  Height:  |  Size: 399 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M27 38H6V17h4v-4h3.5V9h24v4H41v11H27v3h7v4h-3.5v3.5H27zM16.5 20.5H20V17h-3.5z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M27 38H6V17H10V13H13.5V9H37.5V13H41V24H27V27H34V31H30.5V34.5H27V38ZM16.5 20.5H20V17H16.5V20.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M46 46H2V2h44zM16.667 33.777h4.889V28.89h-4.889zm-4.89-4.888h4.89V24h-4.89zm9.779 0h4.888V24h-4.888zM26.444 24h4.889v-4.889h-4.889zm4.889-9.777v4.888h4.89v-4.888z"/></svg>

Before

Width:  |  Height:  |  Size: 263 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M36.888 11H41.3v4.413h-4.412zm-4.413 8.825v-4.412h4.413v4.412zm-4.413 4.413v-4.413h4.413v4.413zM23.65 28.65h4.413v-4.412H23.65zm-4.412 4.413h4.412V28.65h-4.412zm-4.413 0v4.412h4.413v-4.413zm-4.412-4.413h4.412v4.413h-4.412zm0 0H6v-4.412h4.413z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor">
<path d="M36.888 11H41.3v4.413h-4.412zm-4.413 8.825v-4.412h4.413v4.412zm-4.413 4.413v-4.413h4.413v4.413zM23.65 28.65h4.413v-4.412H23.65zm-4.412 4.413h4.412V28.65h-4.412zm-4.413 0v4.412h4.413v-4.413zm-4.412-4.413h4.412v4.413h-4.412zm0 0H6v-4.412h4.413z"/>
</svg>

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 349 B

View File

@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M57.709 20.105H68.62c1.157 0 2.099-.94 2.099-2.095V9.632a2.1 2.1 0 0 0-2.099-2.095h-3.439c-1.111 0-2.014-.9-2.014-2.01V2.095A2.1 2.1 0 0 0 61.07 0H30.02a2.1 2.1 0 0 0-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01H22.47c-1.157 0-2.098.94-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01h-3.439c-1.157 0-2.099.94-2.099 2.094 0 0-.503-1.272-.503 22.493 0 21.247.503 19.38.503 19.38 0 1.156.942 2.096 2.1 2.096h3.438c1.111 0 2.014.9 2.014 2.01v3.517c0 1.109.902 2.01 2.013 2.01h3.524c1.111 0 2.014.9 2.014 2.01v3.432a2.1 2.1 0 0 0 2.098 2.094h30.211c1.157 0 2.099-.94 2.099-2.094v-3.433c0-1.11.902-2.01 2.013-2.01h5.557c1.158 0 2.099-.94 2.099-2.094v-9.984a2.1 2.1 0 0 0-2.099-2.095h-13.03c-1.157 0-2.098.94-2.098 2.095v5.044c0 1.11-.902 2.01-2.014 2.01H37.488c-1.111 0-2.013-.9-2.013-2.01v-5.11a2.1 2.1 0 0 0-2.099-2.094h-5.119c-1.111 0-1.739.163-2.014-2.01-.085-.698-.13-1.553-.196-2.695-.163-2.878-.307-1.723-.307-10.369 0-12.085.314-15.563.503-17.24.19-1.677.903-2.01 2.014-2.01h5.12c1.156 0 2.098-.94 2.098-2.094v-3.433c0-1.109.902-2.01 2.013-2.01h16.116c1.111 0 2.014.901 2.014 2.01v3.433c0 1.155.94 2.094 2.098 2.094zM18.626 73.757h-2.478a.87.87 0 0 1-.87-.868v-2.473c0-.96-.777-1.743-1.745-1.743H6.838c-.96 0-1.745.777-1.745 1.743v2.473a.87.87 0 0 1-.87.868H1.746c-.961 0-1.746.776-1.746 1.742v6.682c0 .96.778 1.742 1.746 1.742h2.477c.484 0 .87.392.87.868v2.473c0 .96.778 1.743 1.745 1.743h6.695c.961 0 1.746-.777 1.746-1.743v-2.473c0-.483.392-.868.87-.868h2.477c.961 0 1.746-.776 1.746-1.742v-6.682c0-.96-.778-1.742-1.746-1.742"/></g><defs><clipPath id="a"><path d="M0 0h72v89H0z"/></clipPath></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="89" fill="currentColor">
<g clip-path="url(#a)">
<path d="M57.709 20.105H68.62c1.157 0 2.099-.94 2.099-2.095V9.632a2.1 2.1 0 0 0-2.099-2.095h-3.439c-1.111 0-2.014-.9-2.014-2.01V2.095A2.1 2.1 0 0 0 61.07 0H30.02a2.1 2.1 0 0 0-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01H22.47c-1.157 0-2.098.94-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01h-3.439c-1.157 0-2.099.94-2.099 2.094 0 0-.503-1.272-.503 22.493 0 21.247.503 19.38.503 19.38 0 1.156.942 2.096 2.1 2.096h3.438c1.111 0 2.014.9 2.014 2.01v3.517c0 1.109.902 2.01 2.013 2.01h3.524c1.111 0 2.014.9 2.014 2.01v3.432a2.1 2.1 0 0 0 2.098 2.094h30.211c1.157 0 2.099-.94 2.099-2.094v-3.433c0-1.11.902-2.01 2.013-2.01h5.557c1.158 0 2.099-.94 2.099-2.094v-9.984a2.1 2.1 0 0 0-2.099-2.095h-13.03c-1.157 0-2.098.94-2.098 2.095v5.044c0 1.11-.902 2.01-2.014 2.01H37.488c-1.111 0-2.013-.9-2.013-2.01v-5.11a2.1 2.1 0 0 0-2.099-2.094h-5.119c-1.111 0-1.739.163-2.014-2.01-.085-.698-.13-1.553-.196-2.695-.163-2.878-.307-1.723-.307-10.369 0-12.085.314-15.563.503-17.24.19-1.677.903-2.01 2.014-2.01h5.12c1.156 0 2.098-.94 2.098-2.094v-3.433c0-1.109.902-2.01 2.013-2.01h16.116c1.111 0 2.014.901 2.014 2.01v3.433c0 1.155.94 2.094 2.098 2.094zM18.626 73.757h-2.478a.87.87 0 0 1-.87-.868v-2.473c0-.96-.777-1.743-1.745-1.743H6.838c-.96 0-1.745.777-1.745 1.743v2.473a.87.87 0 0 1-.87.868H1.746c-.961 0-1.746.776-1.746 1.742v6.682c0 .96.778 1.742 1.746 1.742h2.477c.484 0 .87.392.87.868v2.473c0 .96.778 1.743 1.745 1.743h6.695c.961 0 1.746-.777 1.746-1.743v-2.473c0-.483.392-.868.87-.868h2.477c.961 0 1.746-.776 1.746-1.742v-6.682c0-.96-.778-1.742-1.746-1.742"/>
</g>
<defs>
<clipPath id="a">
<path d="M0 0h72v89H0z"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="223" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M55.503 18.696h10.104a1.946 1.946 0 0 0 1.943-1.948v-7.79c0-1.075-.87-1.947-1.943-1.947h-3.186a1.863 1.863 0 0 1-1.866-1.87V1.947C60.555.872 59.685 0 58.612 0h-27.98a1.946 1.946 0 0 0-1.944 1.947v3.194c0 1.036-.832 1.87-1.865 1.87h-3.187a1.946 1.946 0 0 0-1.943 1.947v3.194c0 1.036-.832 1.87-1.866 1.87h-3.186a1.946 1.946 0 0 0-1.943 1.947s-.467 1.153-.467 23.253c0 19.763.467 21.913.467 21.913 0 1.075.87 1.948 1.943 1.948h3.186c1.034 0 1.866.833 1.866 1.87v3.271c0 1.036.831 1.87 1.865 1.87h3.265c1.033 0 1.865.833 1.865 1.87v3.193c0 1.075.87 1.948 1.943 1.948h27.981a1.946 1.946 0 0 0 1.943-1.948v-3.194c0-1.036.832-1.87 1.866-1.87h5.145a1.946 1.946 0 0 0 1.943-1.947v-9.285c0-1.075-.87-1.948-1.943-1.948H55.503a1.946 1.946 0 0 0-1.943 1.948v4.69c0 1.035-.832 1.869-1.866 1.869H37.55a1.863 1.863 0 0 1-1.866-1.87v-4.752c0-1.075-.87-1.947-1.943-1.947H29c-1.034 0-1.609.148-1.865-1.87-.078-.646-.125-1.44-.18-2.508-.147-2.68-.287-5.5-.287-13.539 0-11.24.288-16.81.466-18.369.18-1.558.832-1.87 1.866-1.87h4.741a1.946 1.946 0 0 0 1.943-1.947v-3.193c0-1.037.832-1.87 1.866-1.87h14.145c1.034 0 1.866.833 1.866 1.87v3.193c0 1.075.87 1.948 1.943 1.948M20.247 74.822h-2.293a.814.814 0 0 1-.808-.81v-2.298c0-.896-.723-1.62-1.617-1.62H9.327c-.894 0-1.617.724-1.617 1.62v2.298c0 .444-.365.81-.808.81H4.609c-.894 0-1.617.725-1.617 1.62v6.217c0 .896.723 1.62 1.617 1.62h2.293c.443 0 .808.366.808.81v2.299c0 .895.723 1.62 1.617 1.62h6.202c.894 0 1.617-.725 1.617-1.62v-2.299c0-.444.365-.81.808-.81h2.293c.894 0 1.617-.724 1.617-1.62v-6.216c0-.896-.723-1.62-1.617-1.62M221.135 35.04h-1.71a1.863 1.863 0 0 1-1.866-1.87v-3.272c0-1.036-.831-1.87-1.865-1.87h-3.265a1.863 1.863 0 0 1-1.865-1.87v-3.271c0-1.036-.832-1.87-1.865-1.87h-20.971a1.863 1.863 0 0 0-1.865 1.87v3.965c0 .514-.42.935-.933.935h-3.559c-.513 0-.84-.32-.933-.935l-.622-3.918c-.148-1.099-.676-1.777-1.788-1.777l-3.653-.14h-2.052a3.736 3.736 0 0 0-3.73 3.74V61.68a3.736 3.736 0 0 1-3.731 3.739h-8.394a1.863 1.863 0 0 1-1.866-1.87V36.714c0-11.825-7.461-18.813-22.556-18.813-13.718 0-20.325 5.04-21.203 14.443-.109 1.153.552 1.815 1.702 1.815l7.757.569c1.143.1 1.594-.554 1.811-1.652.77-3.74 4.174-5.827 9.933-5.827 7.081 0 10.042 3.358 10.042 9.076v3.014c0 1.036-.831 1.87-1.865 1.87l-.342-.024h-9.715c-15.421 0-22.984 5.983-22.984 17.956 0 3.802.778 7.058 2.254 9.738h-.59c-1.765-1.27-2.457-2.236-3.055-2.93-.256-.295-.653-.537-1.345-.537h-1.717l-5.993.008h-3.264a3.736 3.736 0 0 1-3.731-3.74V1.769C89.74.654 89.072 0 87.969 0H79.55c-1.034 0-1.865.732-1.865 1.768l-.024 54.304v13.554c0 4.13 3.343 7.479 7.462 7.479h50.84c8.448-.429 8.604-3.42 9.436-4.542.645 3.56 1.865 4.347 4.71 4.518 8.137.117 18.343.032 18.49.024h4.975c4.119 0 6.684-3.35 6.684-7.479l.777-27.264c0-1.036.832-1.87 1.866-1.87h2.021a1.56 1.56 0 0 0 1.554-1.558v-3.583c0-1.036.832-1.87 1.866-1.87h11.868a3.37 3.37 0 0 1 3.366 3.373v3.249c0 1.075.87 1.947 1.943 1.947h4.119c.513 0 .933.42.933.935v32.25c0 1.036.831 1.87 1.865 1.87h6.84a3.736 3.736 0 0 0 3.731-3.74V36.91c0-1.036-.832-1.87-1.866-1.87zM142.64 54.225c0 8.927-6.132 14.715-15.335 14.715-6.606 0-9.793-2.953-9.793-8.748 0-6.442 3.832-9.636 11.62-9.636h13.508v3.669"/></g><defs><clipPath id="a"><path d="M0 0h223v89H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M35.667 7.667h4.666v4.666H45v23.334h-4.667v4.666h-4.666V45H12.333v-4.667H7.667v-4.666H3V12.333h4.667V7.667h4.666V3h23.334zM15 29.4V33h3.6v-3.6zm14.4 0V33H33v-3.6zm-10.8-3.6v3.6h3.6v-3.6zm7.2 0v3.6h3.6v-3.6zm-3.6-3.6v3.6h3.6v-3.6zm-3.6-3.6v3.6h3.6v-3.6zm7.2 0v3.6h3.6v-3.6zM15 15v3.6h3.6V15zm14.4 0v3.6H33V15z"/></svg>

Before

Width:  |  Height:  |  Size: 409 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M15.6 9h4.2v4.286h-4.2zM11.4 13.286h4.2v4.285h-4.2zM7.2 17.571h4.2v4.286H7.2zM3 21.857h4.2v4.286H3zM7.2 26.143h4.2v4.286H7.2zM11.4 30.429h4.2v4.285h-4.2zM15.6 34.714h4.2V39h-4.2zM32.4 9h-4.2v4.286h4.2zM36.6 13.286h-4.2v4.285h4.2zM40.8 17.571h-4.2v4.286h4.2zM45 21.857h-4.2v4.286H45zM40.8 26.143h-4.2v4.286h4.2z"/><path d="M36.6 30.429h-4.2v4.285h4.2zM32.4 34.714h-4.2V39h4.2z"/></svg>

Before

Width:  |  Height:  |  Size: 476 B

View File

@@ -1 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M8 9h6v6H8zM14 9h6v6h-6zM20 9h6v6h-6zM14 15h6v6h-6zM26 21h6v6h-6zM26 15h6v6h-6zM20 27h6v6h-6zM20 21h6v6h-6zM20 15h6v6h-6zM8 3h6v6H8zM14 3h6v6h-6zM32 21h6v6h-6zM8 15h6v6H8zM14 21h6v6h-6zM8 21h6v6H8zM8 27h6v6H8zM8 33h6v6H8zM8 39h6v6H8zM14 27h6v6h-6zM26 27h6v6h-6zM32 27h6v6h-6z"/><path d="M37 27h6v6h-6zM14 33h6v6h-6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="42" viewBox="0 0 35 42" fill="currentColor">
<rect y="6" width="6" height="6"/>
<rect x="6" y="6" width="6" height="6"/>
<rect x="12" y="6" width="6" height="6"/>
<rect x="6" y="12" width="6" height="6"/>
<rect x="18" y="18" width="6" height="6"/>
<rect x="18" y="12" width="6" height="6"/>
<rect x="12" y="24" width="6" height="6"/>
<rect x="12" y="18" width="6" height="6"/>
<rect x="12" y="12" width="6" height="6"/>
<rect width="6" height="6"/>
<rect x="6" width="6" height="6"/>
<rect x="24" y="18" width="6" height="6"/>
<rect y="12" width="6" height="6"/>
<rect x="6" y="18" width="6" height="6"/>
<rect y="18" width="6" height="6"/>
<rect y="24" width="6" height="6"/>
<rect y="30" width="6" height="6"/>
<rect y="36" width="6" height="6"/>
<rect x="6" y="24" width="6" height="6"/>
<rect x="18" y="24" width="6" height="6"/>
<rect x="24" y="24" width="6" height="6"/>
<rect x="29" y="24" width="6" height="6"/>
<rect x="6" y="30" width="6" height="6"/>
</svg>

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M11.7 9h8.1v5.625H42v3.75H19.8V24h-8.1v-5.625H5v-3.75h6.7zm15.5 15h8.1v5.625H42v3.75h-6.7V39h-8.1v-5.625H5v-3.75h22.2z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M8.7 9h11.1v5.625H42v3.75H19.8V24H8.7v-5.625H5v-3.75h3.7zm3.7 3.75v7.5h3.7v-7.5zM27.2 24h11.1v5.625H42v3.75h-3.7V39H27.2v-5.625H5v-3.75h22.2zm3.7 3.75v7.5h3.7v-7.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M34.25 9.3H45v34.4H2.001v-4.3H2V13.6h.001V9.3H12.75V5h21.5zM19.201 30.8v4.3h8.6v-4.3zm-4.3-4.3v4.3h4.3v-4.3zm12.9 0v4.3h4.3v-4.3zm-12.9-8.6v4.3h4.3v-4.3zm12.9 0v4.3h4.3v-4.3z"/></svg>

Before

Width:  |  Height:  |  Size: 275 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M20.2 12.8H23v2.8h2.8v-1.4h2.8V10h5.6v2.8H37v2.8h2.8V24H37v2.8h-2.8v2.8h-2.8v2.8h-2.8v2.8h-2.8V38H23v-2.8h-2.8v-2.8h-2.8v-2.8h-2.8v-2.8h-2.8V24H9v-8.4h2.8v-2.8h2.8V10h5.6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M20.2002 12.7998H23V15.5996H25.8008V14.2002H28.6006V10H34.2002V12.7998H37V15.5996H39.8008V24H37V26.7998H34.2002V29.5996H31.4004V32.4004H28.6006V35.2002H25.8008V38H23V35.2002H20.2002V32.4004H17.4004V29.5996H14.6006V26.7998H11.8008V24H9V15.5996H11.8008V12.7998H14.6006V10H20.2002V12.7998Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M38.666 5v34.667h.001V5H43v39H4V5zm-26 30.334h4.333V31h-4.333zm17.333 0h4.334V31h-4.334zm-8.666-8.667h4.333v-4.333h-4.333zM12.666 18h4.333v-4.333h-4.333zm17.333 0h4.334v-4.333h-4.334z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M38.666 5V39.667H38.667V5H43V44H4V5H38.666ZM12.666 35.334H16.999V31H12.666V35.334ZM29.999 35.334H34.333V31H29.999V35.334ZM21.333 26.667H25.666V22.334H21.333V26.667ZM12.666 18H16.999V13.667H12.666V18ZM29.999 18H34.333V13.667H29.999V18Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M7.667 45H3v-4.667h4.667zM17 45h-4.667v-4.667H17zm9.333 0h-4.666v-4.667h4.666zm9.334 0H31v-4.667h4.667zM45 45h-4.667v-4.667H45zM7.667 35.667H3V31h4.667zm37.333 0h-4.667V31H45zM7.667 26.333H3v-4.666h4.667zm37.333 0h-4.667V7.667H21.667V3H45zM7.667 17H3v-4.667h4.667zm0-9.333H3V3h4.667zm9.333 0h-4.667V3H17z"/></svg>

Before

Width:  |  Height:  |  Size: 405 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M38 42H10v-4H6V10h4V6h28v4h4v28h-4zM18 32h12v-4H18zm-4-4h4v-4h-4zm16 0h4v-4h-4zm-14-8h4v-4h-4zm12 0h4v-4h-4z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M38 42H10V38H6V10H10V6H38V10H42V38H38V42ZM18 32H30V28H18V32ZM14 28H18V24H14V28ZM30 28H34V24H30V28ZM16 20H20V16H16V20ZM28 20H32V16H28V20Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 209 B

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M42 42H14v-4h24V14h4zM34 6v28H6V6zM18 18h-4v4h4v4h4v-4h4v-4h-4v-4h-4z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M42 42H14V38H38V14H42V42ZM34 6V34H6V6H34ZM18 18H14V22H18V26H22V22H26V18H22V14H18V18Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -1 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M39.247 38H8.753v-4.148h30.494zM9.223 22.923H5v-4.308h4.223zm8.444 0h-4.223v-4.308h4.223zm16.889 0h-4.223v-4.308h4.223zm8.444 0h-4.223v-4.308H43zm-29.556-4.308H9.223v-4.307h4.221zm25.333 0h-4.221v-4.307h4.221zM9.223 14.308H5V10h4.223zm8.444 0h-4.223V10h4.223zm16.889 0h-4.223V10h4.223zm8.444 0h-4.223V10H43z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="27" viewBox="0 0 38 27" fill="currentColor">
<rect x="4.46155" y="4.15381" width="4.15385" height="4.15385"/>
<rect x="29.3846" y="4.15381" width="4.15385" height="4.15385"/>
<rect x="8.61539" width="4.15385" height="4.15385"/>
<rect x="33.5385" width="4.15385" height="4.15385"/>
<rect x="0.307678" width="4.15385" height="4.15385"/>
<rect x="25.2308" width="4.15385" height="4.15385"/>
<rect x="0.307678" y="8.30762" width="4.15385" height="4.15385"/>
<rect x="25.2308" y="8.30762" width="4.15385" height="4.15385"/>
<rect x="8.61539" y="8.30762" width="4.15385" height="4.15385"/>
<rect x="33.5385" y="8.30762" width="4.15385" height="4.15385"/>
<rect x="4" y="23" width="30" height="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 801 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M39.2 39.2H43V43h-3.8v-3.798h-3.8v-3.8h3.8zM27.8 8.8h3.8v22.802h-3.8V35.4H12.6V12.602h7.6V8.8h-7.6V5h15.2zm7.6 26.6h-3.8v-3.8h3.8zM12.6 12.6H8.8v7.6h3.8v11.402H8.8V27.8H5V12.6h3.8V8.8h3.8zm22.8 15.2h-3.8V12.6h3.8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M39.2002 39.2002H43V43H39.2002V39.2021H35.3994V35.4014H39.2002V39.2002ZM27.7998 8.80078H31.5996V31.6016H27.7998V35.4004H12.6006V12.6016H20.2002V8.80078H12.6006V5H27.7998V8.80078ZM35.4004 35.4004H31.6006V31.6006H35.4004V35.4004ZM12.5996 12.5996H8.7998V20.2002H12.5996V31.6016H8.7998V27.8008H5V12.5996H8.7998V8.80078H12.5996V12.5996ZM35.4004 27.8008H31.6006V12.5996H35.4004V27.8008Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M43 43H5V20.2h38zm-3.8-26.6H8.8v-3.8h30.4zm-3.8-7.6H12.6V5h22.8z"/></svg>

Before

Width:  |  Height:  |  Size: 165 B

View File

@@ -1 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M33 13v22.5h-4.5V13zM37.5 17.5V31H33V17.5zM42 22v4.5h-4.5V22zM15 13v22.5h4.5V13zM10.5 17.5V31H15V17.5zM6 22v4.5h4.5V22z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="23" viewBox="0 0 36 23" fill="currentColor">
<rect x="27" width="22.5" height="4.5" transform="rotate(90 27 0)"/>
<rect x="31.5" y="4.5" width="13.5" height="4.5" transform="rotate(90 31.5 4.5)"/>
<rect x="36" y="9" width="4.5" height="4.5" transform="rotate(90 36 9)"/>
<rect width="22.5" height="4.5" transform="matrix(-4.37114e-08 1 1 4.37114e-08 9 0)"/>
<rect width="13.5" height="4.5" transform="matrix(-4.37114e-08 1 1 4.37114e-08 4.5 4.5)"/>
<rect width="4.5" height="4.5" transform="matrix(-4.37114e-08 1 1 4.37114e-08 0 9)"/>
</svg>

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 624 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M39.4 31.2h-3.6v3.6h-3.6v3.6h-3.6V42H25v-3.6h-3.6v-3.6h-3.6v-3.6h-3.6v-3.6h25.2zm-10.8-18h3.6v3.6h3.6v3.6h3.6V24H43v3.6H10.6V24H7V9.6h21.6zm-14.4 0v3.6h3.6v-3.6zM25 9.6H7V6h18z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M39.4004 31.2002H35.7998V34.7998H32.2002V38.3994H28.5996V42H25V38.3994H21.4004V34.7998H17.7998V31.2002H14.2002V27.6006H39.4004V31.2002ZM28.5996 13.2002H32.2002V16.7998H35.7998V20.3994H39.4004V24H43V27.5996H10.5996V24H7V9.60059H28.5996V13.2002ZM14.2002 13.2002V16.7998H17.7998V13.2002H14.2002ZM25 9.59961H7V6H25V9.59961Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M18.8 0h9.6v9.6h-9.6zm-7.2 12h24v4.8h-4.8V48H26V33.6h-4.8V48h-4.8V16.8h-4.8zM6.8 7.2V12h4.8V7.2zm0 0H2V2.4h4.8zm33.6 0V12h-4.8V7.2zm0 0V2.4h4.8v4.8z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8 0H28.4V9.6H18.8V0ZM11.6 12H35.6V16.8H30.8V33.6V48H26V33.6H21.2V48H16.4V33.6V16.8H11.6V12ZM6.8 7.2V12H11.6V7.2H6.8ZM6.8 7.2L2 7.2V2.4H6.8V7.2ZM40.4 7.2V12H35.6V7.2H40.4ZM40.4 7.2L40.4 2.4H45.2V7.2L40.4 7.2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 378 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M26 6h4v4h4v4h4v4h4v4h4v4h-4v4h-4v4h-4v4h-4v4h-4v4h-4v-4h-4v-4h-4v-4h-4v-4H6v-4H2v-4h4v-4h4v-4h4v-4h4V6h4V2h4zm-4 24v4h4v-4zm0-16v12h4V14z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M42 6H5v37h37zm-20.555 8.222h4.111v12.334h-4.111zm0 16.445h4.111v4.11h-4.111z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 218 B

View File

@@ -15,11 +15,9 @@
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@tanstack/solid-query": "^5.85.5",
"@tanstack/solid-query-devtools": "^5.85.5",
"@tanstack/solid-query-persist-client": "^5.85.5",
"@tanstack/solid-virtual": "^3.13.12",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"@tanstack/solid-query-devtools": "^5.83.0",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0",
"three": "^0.176.0",
@@ -2489,12 +2487,12 @@
}
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.83.1",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.83.1.tgz",
"integrity": "sha512-tdkpPFfzkTksN9BIlT/qjixSAtKrsW6PUVRwdKWaOcag7DrD1vpki3UzzdfMQGDRGeg1Ue1Dg+rcl5FJGembNg==",
"version": "5.81.2",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.81.2.tgz",
"integrity": "sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.37.0"
"@typescript-eslint/utils": "^8.18.1"
},
"funding": {
"type": "github",
@@ -2504,169 +2502,10 @@
"eslint": "^8.57.0 || ^9.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/project-service": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.40.0",
"@typescript-eslint/types": "^8.40.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz",
"integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.40.0",
"@typescript-eslint/visitor-keys": "8.40.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.40.0",
"@typescript-eslint/tsconfig-utils": "8.40.0",
"@typescript-eslint/types": "8.40.0",
"@typescript-eslint/visitor-keys": "8.40.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz",
"integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.40.0",
"@typescript-eslint/types": "8.40.0",
"@typescript-eslint/typescript-estree": "8.40.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.40.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@tanstack/eslint-plugin-query/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
"integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz",
"integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2674,35 +2513,22 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.84.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
"version": "5.81.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.81.2.tgz",
"integrity": "sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.5.tgz",
"integrity": "sha512-2JQiyiTVaaUu8pwPqOp6tjNa64ZN+0T9eZ3lfksV4le1VuG99fTcAYmZFIydvzwWlSM7GEF/1kpl5bwW2Y1qfQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/solid-query": {
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.85.5.tgz",
"integrity": "sha512-0o0Ibk9wqydm4JatbIjmvDu1+MofeZ1bU9BKwAbpt7HYjrLVCeddpW6zGmp41nN7t/mHJyR+ctW9oiNumCkEfg==",
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.83.0.tgz",
"integrity": "sha512-RF8Tv9+6+Kmzj+EafbTzvzzPq+J5SzHtc1Tz3D2MZ/EvlZTH+GL5q4HNnWK3emg7CB6WzyGnTuERmmWJaZs8/w==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.5"
"@tanstack/query-core": "5.83.0"
},
"funding": {
"type": "github",
@@ -2713,65 +2539,22 @@
}
},
"node_modules/@tanstack/solid-query-devtools": {
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query-devtools/-/solid-query-devtools-5.85.5.tgz",
"integrity": "sha512-9rC22wILlV9Lcsi4xKPmzRkNio1NOxNT36diIS+HjpOmhsEP/aI8XkNKQa/KPhhaSN2naYaTCJamh7eBAQ0Ymg==",
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query-devtools/-/solid-query-devtools-5.83.0.tgz",
"integrity": "sha512-Z0wQlAWXz/U2bJ/paMRBTDhMoPnB9Te6GmA21sXnI+nDnAAPZRcPxFBiCgYJS3eFsvbkdRGJwoUSQrdIgy0shg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.84.0"
"@tanstack/query-devtools": "5.81.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/solid-query": "^5.85.5",
"@tanstack/solid-query": "^5.83.0",
"solid-js": "^1.6.0"
}
},
"node_modules/@tanstack/solid-query-persist-client": {
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query-persist-client/-/solid-query-persist-client-5.85.5.tgz",
"integrity": "sha512-2aG7UnLZlfE3R4XKqYuIeXVKjJOghjsjq4EU2Ifp915FTBZcZo61sEw1zRqRlrDjEFYAs4kJUZwqViDSJYyX2g==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.85.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/solid-query": "^5.85.5",
"solid-js": "^1.6.0"
}
},
"node_modules/@tanstack/solid-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.13.12.tgz",
"integrity": "sha512-0dS8GkBTmbuM9cUR6Jni0a45eJbd32CAEbZj8HrZMWIj3lu974NpGz5ywcomOGJ9GdeHuDaRzlwtonBbKV1ihQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"solid-js": "^1.3.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -3111,7 +2894,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz",
"integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.36.0",
@@ -3133,7 +2915,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz",
"integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
@@ -3151,7 +2932,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz",
"integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3192,7 +2972,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz",
"integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3206,7 +2985,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz",
"integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.36.0",
@@ -3235,7 +3013,6 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -3248,7 +3025,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz",
"integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
@@ -3272,7 +3048,6 @@
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz",
"integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.36.0",
@@ -3290,7 +3065,6 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -72,11 +72,9 @@
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@tanstack/solid-query": "^5.85.5",
"@tanstack/solid-query-devtools": "^5.85.5",
"@tanstack/solid-query-persist-client": "^5.85.5",
"@tanstack/solid-virtual": "^3.13.12",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"@tanstack/solid-query-devtools": "^5.83.0",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0",
"three": "^0.176.0",

View File

@@ -5,14 +5,3 @@
.horizontal_button {
@apply grow max-w-[18rem];
}
/* Vendored from tooltip */
.tooltipContent {
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;
max-width: min(calc(100vw - 16px), 380px);
&.inverted {
@apply bg-def-2;
}
}

View File

@@ -87,7 +87,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
{value() && (
<Tooltip placement="top">
<Tooltip.Portal>
<Tooltip.Content class={styles.tooltipContent}>
<Tooltip.Content class="tooltip-content">
<Typography
hierarchy="body"
size="xs"

View File

@@ -55,14 +55,23 @@ export const Label = (props: LabelProps) => {
<Tooltip
placement="top"
inverted={props.inverted}
description={props.tooltip}
trigger={
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
}
>
<Icon
icon="Info"
color="tertiary"
inverted={props.inverted}
size={props.size == "default" ? "0.85em" : "0.75rem"}
/>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!props.inverted}
>
{props.tooltip}
</Typography>
</Tooltip>
)}
</props.labelComponent>

View File

@@ -27,7 +27,7 @@ div.form-field.machine-tags {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:not(:read-only):focus-visible {
&:focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
@@ -106,7 +106,7 @@ div.form-field.machine-tags {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:not(:read-only):focus-visible {
&:focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),

View File

@@ -17,7 +17,7 @@ div.form-field {
@apply bg-def-acc-1 outline-def-acc-2;
}
&:not(:read-only):focus-visible {
&:focus-visible {
@apply bg-def-1 outline-def-acc-3;
box-shadow:
@@ -119,7 +119,7 @@ div.form-field {
@apply bg-inv-acc-2 outline-inv-acc-2;
}
&:not(:read-only):focus-visible {
&:focus-visible {
@apply bg-inv-acc-4;
box-shadow:
0 0 0 0.125rem theme(colors.bg.inv.1),

View File

@@ -1,8 +1,5 @@
import cx from "classnames";
import { Component, JSX, splitProps } from "solid-js";
import Address from "@/icons/address.svg";
import AI from "@/icons/ai.svg";
import ArrowBottom from "@/icons/arrow-bottom.svg";
import ArrowLeft from "@/icons/arrow-left.svg";
import ArrowRight from "@/icons/arrow-right.svg";
@@ -13,12 +10,9 @@ import CaretLeft from "@/icons/caret-left.svg";
import CaretRight from "@/icons/caret-right.svg";
import CaretUp from "@/icons/caret-up.svg";
import Checkmark from "@/icons/checkmark.svg";
import CheckSolid from "@/icons/check-solid.svg";
import ClanIcon from "@/icons/clan-icon.svg";
import Close from "@/icons/close.svg";
import CloseCircle from "@/icons/close-circle.svg";
import Code from "@/icons/code.svg";
import Cursor from "@/icons/cursor.svg";
import Close from "@/icons/close.svg";
import Download from "@/icons/download.svg";
import Edit from "@/icons/edit.svg";
import Expand from "@/icons/expand.svg";
@@ -27,39 +21,35 @@ import EyeOpen from "@/icons/eye-open.svg";
import Filter from "@/icons/filter.svg";
import Flash from "@/icons/flash.svg";
import Folder from "@/icons/folder.svg";
import General from "@/icons/general.svg";
import Grid from "@/icons/grid.svg";
import Heart from "@/icons/heart.svg";
import Info from "@/icons/info.svg";
import List from "@/icons/list.svg";
import Load from "@/icons/load.svg";
import Machine from "@/icons/machine.svg";
import Minimize from "@/icons/minimize.svg";
import Modules from "@/icons/modules.svg";
import More from "@/icons/more.svg";
import NewMachine from "@/icons/new-machine.svg";
import Offline from "@/icons/offline.svg";
import Paperclip from "@/icons/paperclip.svg";
import Plus from "@/icons/plus.svg";
import Reload from "@/icons/reload.svg";
import Report from "@/icons/report.svg";
import Search from "@/icons/search.svg";
import SearchFilled from "@/icons/search-filled.svg";
import Services from "@/icons/services.svg";
import Settings from "@/icons/settings.svg";
import Switch from "@/icons/switch.svg";
import Tag from "@/icons/tag.svg";
import Trash from "@/icons/trash.svg";
import Update from "@/icons/update.svg";
import User from "@/icons/user.svg";
import WarningFilled from "@/icons/warning-filled.svg";
import Modules from "@/icons/modules.svg";
import NewMachine from "@/icons/new-machine.svg";
import AI from "@/icons/ai.svg";
import User from "@/icons/user.svg";
import Heart from "@/icons/heart.svg";
import SearchFilled from "@/icons/search-filled.svg";
import Offline from "@/icons/offline.svg";
import Switch from "@/icons/switch.svg";
import Tag from "@/icons/tag.svg";
import Machine from "@/icons/machine.svg";
import { Dynamic } from "solid-js/web";
import { Color, fgClass } from "../colors";
const icons = {
Address,
AI,
ArrowBottom,
ArrowLeft,
@@ -71,11 +61,8 @@ const icons = {
CaretRight,
CaretUp,
Checkmark,
CheckSolid,
ClanIcon,
Close,
CloseCircle,
Code,
Cursor,
Download,
Edit,
@@ -85,14 +72,12 @@ const icons = {
Filter,
Flash,
Folder,
General,
Grid,
Heart,
Info,
List,
Load,
Machine,
Minimize,
Modules,
More,
NewMachine,
@@ -103,7 +88,6 @@ const icons = {
Report,
Search,
SearchFilled,
Services,
Settings,
Switch,
Tag,
@@ -117,6 +101,8 @@ export type IconVariant = keyof typeof icons;
const viewBoxes: Partial<Record<IconVariant, string>> = {
ClanIcon: "0 0 72 89",
Offline: "0 0 38 27",
Cursor: "0 0 35 42",
};
export interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {

View File

@@ -1,15 +0,0 @@
.modal {
@apply w-screen max-w-2xl h-fit flex flex-col;
.content {
@apply flex flex-col gap-2;
}
.header {
@apply flex items-center justify-between;
}
.clans {
@apply flex flex-col gap-2;
}
}

View File

@@ -1,95 +0,0 @@
import { Modal } from "../../components/Modal/Modal";
import cx from "classnames";
import styles from "./ListClansModal.module.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan";
import { useNavigate } from "@solidjs/router";
import { For, Show } from "solid-js";
import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { useClanListQuery } from "@/src/hooks/queries";
import { Alert } from "@/src/components/Alert/Alert";
import { NavSection } from "../NavSection/NavSection";
export interface ListClansModalProps {
onClose?: () => void;
error?: {
title: string;
description: string;
};
}
export const ListClansModal = (props: ListClansModalProps) => {
const navigate = useNavigate();
const query = useClanListQuery(clanURIs());
// we only want clans we could interrogate successfully
// todo how to surface the ones that failed to users?
const clanList = () => query.filter((it) => it.isSuccess);
const selectClan = (uri: string) => () => {
if (uri == activeClanURI()) {
navigateToClan(navigate, uri);
} else {
setActiveClanURI(uri);
}
};
return (
<Modal
title="Select Clan"
open
onClose={props.onClose}
class={cx(styles.modal)}
>
<div class={cx(styles.content)}>
<Show when={props.error}>
<Alert
type="error"
title={props.error?.title || ""}
description={props.error?.description}
/>
</Show>
<div class={cx(styles.header)}>
<Typography
hierarchy="label"
family="mono"
size="xs"
color="tertiary"
transform="uppercase"
>
Your Clans
</Typography>
<Button
hierarchy="secondary"
ghost
size="s"
startIcon="Plus"
onClick={() => {
props.onClose?.();
navigateToOnboarding(navigate, true);
}}
>
Add Clan
</Button>
</div>
<ul class={cx(styles.clans)}>
<For each={clanList()}>
{(clan) => (
<li>
<NavSection
label={clan.data.name}
description={clan.data.description ?? undefined}
onClick={selectClan(clan.data.uri)}
/>
</li>
)}
</For>
</ul>
</div>
</Modal>
);
};

View File

@@ -37,8 +37,8 @@
}
.backdrop {
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center z-50;
@apply bg-inv-4 opacity-70;
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
}
.contentWrapper {

View File

@@ -13,9 +13,9 @@ import Icon from "../Icon/Icon";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
export interface ModalContextType {
export type ModalContextType = {
portalRef: HTMLDivElement;
}
};
const ModalContext = createContext<unknown>();
@@ -30,7 +30,7 @@ export const useModalContext = () => {
export interface ModalProps {
id?: string;
title: string;
onClose?: () => void;
onClose: () => void;
children: JSX.Element;
mount?: Node;
class?: string;
@@ -57,11 +57,13 @@ export const Modal = (props: ModalProps) => {
>
{props.title}
</Typography>
<Show when={props.onClose}>
<KDialog.CloseButton onClick={props.onClose}>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</Show>
<KDialog.CloseButton
onClick={() => {
props.onClose();
}}
>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<Show when={props.metaHeader}>
{(metaHeader) => (

View File

@@ -1,16 +0,0 @@
.navSection {
@apply w-full flex gap-1.5 items-center justify-between;
@apply rounded-md px-4 py-5 bg-def-2;
.meta {
@apply flex flex-col gap-1;
}
&:hover {
@apply bg-def-3 cursor-pointer;
}
&:active {
@apply bg-def-4;
}
}

View File

@@ -1,35 +0,0 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
NavSection,
NavSectionProps,
} from "@/src/components/NavSection/NavSection";
const meta: Meta<NavSectionProps> = {
title: "Components/NavSection",
component: NavSection,
decorators: [
(Story: StoryObj) => (
<div class="w-96">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<NavSectionProps>;
export const Default: Story = {
args: {
label: "My Clan",
},
};
export const WithDescription: Story = {
args: {
...Default.args,
description:
"This is my Clan. There are many Clans like it, but this one is mine",
},
};

View File

@@ -1,35 +0,0 @@
import cx from "classnames";
import styles from "./NavSection.module.css";
import { Button } from "@kobalte/core/button";
import Icon from "../Icon/Icon";
import { Typography } from "../Typography/Typography";
import { Show } from "solid-js";
export interface NavSectionProps {
label: string;
description?: string;
onClick: () => void;
}
export const NavSection = (props: NavSectionProps) => {
return (
<Button class={cx(styles.navSection)} onClick={props.onClick}>
<div class={cx(styles.meta)}>
<Typography hierarchy="label" size="default" weight="bold">
{props.label}
</Typography>
<Show when={props.description}>
<Typography
hierarchy="body"
size="s"
weight="normal"
color="secondary"
>
{props.description}
</Typography>
</Show>
</div>
<Icon icon="CaretRight" />
</Button>
);
};

View File

@@ -1,102 +0,0 @@
.searchInput {
@apply w-full bg-inv-4 fg-inv-1;
font-size: 0.875rem;
font-weight: 500;
font-family: "Archivo", sans-serif;
line-height: 132%;
&::placeholder {
@apply fg-def-4;
}
&:focus,
&:focus-visible {
@apply outline-none;
}
&[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 p-0 cursor-auto resize-none;
}
}
.searchHeader {
@apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50;
@apply px-3 pt-3 pb-2;
}
.inputContainer {
@apply flex items-center gap-2 bg-inv-4 rounded-md px-1 w-full;
:has :focus-visible {
@apply bg-def-1;
}
}
.searchItem {
@apply flex py-1 px-2 pr-4 gap-2 justify-between items-center rounded-md;
& [role="option"] {
@apply flex flex-col w-full;
}
/* Icon */
& [role="complementary"] {
@apply size-8 flex items-center justify-center bg-white rounded-md;
}
&[data-highlighted],
&:focus,
&:focus-visible,
&:hover {
@apply bg-inv-acc-2;
}
&:active {
@apply bg-inv-acc-3;
}
}
.searchContainer {
@apply bg-gradient-to-b from-bg-inv-3 to-bg-inv-4;
@apply h-[14.5rem] rounded-lg;
border: 1px solid #2b4647;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%),
linear-gradient(
180deg,
var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%,
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.searchContent {
@apply px-3;
height: calc(14.5rem - 4rem);
}
@keyframes contentHide {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -1,75 +0,0 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Search, SearchProps, Module } from "./Search";
const meta = {
title: "Components/Search",
component: Search,
} satisfies Meta<SearchProps>;
export default meta;
type Story = StoryObj<SearchProps>;
// To test the virtualizer, we can generate a list of modules
function generateModules(count: number): Module[] {
const greek = [
"alpha",
"beta",
"gamma",
"delta",
"epsilon",
"zeta",
"eta",
"theta",
"iota",
"kappa",
"lambda",
"mu",
"nu",
"xi",
"omicron",
"pi",
"rho",
"sigma",
"tau",
"upsilon",
"phi",
"chi",
"psi",
"omega",
];
const modules: Module[] = [];
for (let i = 0; i < count; i++) {
modules.push({
value: `lolcat/module-${i + 1}`,
name: `Module ${i + 1}`,
description: `${greek[i % greek.length]}#${i + 1}`,
input: "lolcat",
});
}
return modules;
}
export const Default: Story = {
args: {
// Test with lots of modules
options: generateModules(1000),
},
render: (args: SearchProps) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<Search
{...args}
onChange={(module) => {
// Go to the module configuration
console.log("Selected module:", module);
}}
/>
</div>
);
},
};

View File

@@ -1,210 +0,0 @@
import Icon from "../Icon/Icon";
import { Button } from "../Button/Button";
import styles from "./Search.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { createMemo, createSignal, For } from "solid-js";
import { Typography } from "../Typography/Typography";
import { createVirtualizer } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
export interface Module {
value: string;
name: string;
input: string;
description: string;
}
export interface SearchProps {
onChange: (value: Module | null) => void;
options: Module[];
}
export function Search(props: SearchProps) {
// Controlled input value, to allow resetting the input itself
const [value, setValue] = createSignal<Module | null>(null);
const [inputValue, setInputValue] = createSignal<string>("");
let inputEl: HTMLInputElement;
let listboxRef: HTMLUListElement;
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
const [comboboxItems, setComboboxItems] = createSignal<
CollectionNode<Module>[]
>(
props.options.map((item) => ({
rawValue: item,
})) as CollectionNode<Module>[],
);
// Create a reactive virtualizer that updates when items change
const virtualizer = createMemo(() => {
const items = comboboxItems();
const newVirtualizer = createVirtualizer({
count: items.length,
getScrollElement: () => listboxRef,
getItemKey: (index) => {
const item = items[index];
return item?.rawValue?.value || `item-${index}`;
},
estimateSize: () => 42,
gap: 6,
overscan: 5,
});
return newVirtualizer;
});
return (
<Combobox<Module>
value={value()}
onChange={(value) => {
setValue(value);
setInputValue(value ? value.name : "");
props.onChange(value);
}}
class={styles.searchContainer}
placement="bottom-start"
options={props.options}
optionValue="value"
optionTextValue="name"
optionLabel="name"
placeholder="Search a service"
sameWidth={true}
open={true}
gutter={7}
modal={false}
flip={false}
virtualized={true}
allowsEmptyCollection={true}
closeOnSelection={false}
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<Module> class={styles.searchHeader}>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={"Search a service"}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<Combobox.Listbox<Module>
ref={(el) => {
listboxRef = el;
}}
style={{
height: "100%",
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<For each={virtualizer().getVirtualItems()}>
{(virtualRow) => {
const item: CollectionNode<Module> | undefined =
items().getItem(virtualRow.key as string);
if (!item) {
console.warn("Item not found for key:", virtualRow.key);
return null;
}
return (
<Combobox.Item
item={item}
class={styles.searchItem}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div role="complementary">
<Icon icon="Code" />
</div>
<div role="option">
<Combobox.ItemLabel class="flex">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.rawValue.name}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span>{item.rawValue.description}</span>
<span>by {item.rawValue.input}</span>
</Typography>
</div>
</Combobox.Item>
);
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
}

View File

@@ -11,13 +11,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { addClanURI, resetStore } from "@/src/stores/clan";
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
import { encodeBase64 } from "@/src/hooks/clan";
import { ApiClientProvider } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationArgs,
OperationNames,
OperationResponse,
} from "@/src/hooks/api";
const defaultClanURI = "/home/brian/clans/my-clan";
@@ -31,16 +24,10 @@ const queryData = {
europa: {
name: "Europa",
machineClass: "nixos",
state: {
status: "online",
},
},
ganymede: {
name: "Ganymede",
machineClass: "nixos",
state: {
status: "out_of_sync",
},
},
},
},
@@ -53,16 +40,10 @@ const queryData = {
callisto: {
name: "Callisto",
machineClass: "nixos",
state: {
status: "not_installed",
},
},
amalthea: {
name: "Amalthea",
machineClass: "nixos",
state: {
status: "offline",
},
},
},
},
@@ -75,16 +56,10 @@ const queryData = {
thebe: {
name: "Thebe",
machineClass: "nixos",
state: {
status: "online",
},
},
sponde: {
name: "Sponde",
machineClass: "nixos",
state: {
status: "online",
},
},
},
},
@@ -148,18 +123,6 @@ export default meta;
type Story = StoryObj<RouteSectionProps>;
const mockFetcher = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
) =>
({
uuid: "mock",
result: Promise.reject<OperationResponse<K>>("not implemented"),
cancel: async () => {
throw new Error("not implemented");
},
}) satisfies ApiCall<K>;
export const Default: Story = {
args: {},
decorators: [
@@ -178,28 +141,16 @@ export const Default: Story = {
["clans", encodeBase64(clanURI), "details"],
clan.details,
);
const machines = clan.machines || {};
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machines"],
machines,
clan.machines || {},
);
Object.entries(machines).forEach(([name, machine]) => {
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machine", name, "state"],
machine.state,
);
});
});
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
</ApiClientProvider>
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
);
},
],

View File

@@ -3,12 +3,11 @@ import { A } from "@solidjs/router";
import { Accordion } from "@kobalte/core/accordion";
import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
import { For, useContext } from "solid-js";
import { For } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar";
import { ClanContext } from "@/src/routes/Clan/Clan";
interface MachineProps {
clanURI: string;
@@ -57,11 +56,7 @@ const MachineRoute = (props: MachineProps) => {
export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI();
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const machineList = useMachinesQuery(clanURI);
const sectionLabels = (props.staticSections || []).map(
(section) => section.title,
@@ -101,7 +96,7 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Header>
<Accordion.Content class="content">
<nav>
<For each={Object.entries(ctx.machinesQuery.data || {})}>
<For each={Object.entries(machineList.data || {})}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}

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 gap-3;
@apply flex flex-col w-full px-2 py-1.5 z-10;
@apply bg-def-1 rounded-bl-md rounded-br-md;
@apply border border-def-2;
@@ -58,7 +58,6 @@ div.sidebar-header {
@apply px-1;
.dropdown-group-label {
@apply flex items-baseline justify-between w-full;
}
.dropdown-group-items {

View File

@@ -3,15 +3,10 @@ import Icon from "@/src/components/Icon/Icon";
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography";
import { createSignal, For, Suspense, useContext } from "solid-js";
import {
navigateToClan,
navigateToOnboarding,
useClanURI,
} from "@/src/hooks/clan";
import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan";
import { createSignal, For, Suspense } from "solid-js";
import { useClanListQuery } from "@/src/hooks/queries";
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
import { clanURIs } from "@/src/stores/clan";
export const SidebarHeader = () => {
const navigate = useNavigate();
@@ -19,23 +14,10 @@ export const SidebarHeader = () => {
const [open, setOpen] = createSignal(false);
// get information about the current active clan
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("SidebarContext not found");
}
const clanURI = useClanURI();
const allClans = useClanListQuery(clanURIs());
const clanChar = () =>
ctx?.activeClanQuery?.data?.name.charAt(0).toUpperCase();
const clanName = () => ctx?.activeClanQuery?.data?.name;
const clanList = () =>
ctx.allClansQueries
.filter((it) => it.isSuccess)
.map((it) => it.data!)
.sort((a, b) => a.name.localeCompare(b.name));
const activeClan = () => allClans.find(({ data }) => data?.uri === clanURI);
return (
<div class="sidebar-header">
@@ -50,7 +32,7 @@ export const SidebarHeader = () => {
weight="bold"
inverted={true}
>
{clanChar()}
{activeClan()?.data?.name.charAt(0).toUpperCase()}
</Typography>
</div>
<Typography
@@ -59,7 +41,7 @@ export const SidebarHeader = () => {
weight="bold"
inverted={!open()}
>
{clanName()}
{activeClan()?.data?.name}
</Typography>
</div>
<DropdownMenu.Icon>
@@ -89,36 +71,26 @@ export const SidebarHeader = () => {
family="mono"
size="xs"
color="tertiary"
transform="uppercase"
>
Your Clans
YOUR CLANS
</Typography>
<Button
hierarchy="secondary"
ghost
size="xs"
startIcon="Plus"
onClick={() => navigateToOnboarding(navigate, true)}
>
Add
</Button>
</DropdownMenu.GroupLabel>
<div class="dropdown-group-items">
<For each={clanList()}>
<For each={allClans}>
{(clan) => (
<Suspense fallback={"Loading..."}>
<DropdownMenu.Item
class="dropdown-item"
onSelect={() => {
setActiveClanURI(clan.uri);
}}
onSelect={() =>
navigateToClan(navigate, clan.data!.uri)
}
>
<Typography
hierarchy="label"
size="xs"
weight="medium"
>
{clan.name}
{clan.data?.name}
</Typography>
</DropdownMenu.Item>
</Suspense>

View File

@@ -5,7 +5,7 @@ div.sidebar-section {
@apply flex items-center justify-between px-1.5;
& > div.controls {
@apply flex items-center justify-end;
@apply flex justify-end gap-2;
}
}

View File

@@ -12,7 +12,8 @@ import {
import { OperationNames, SuccessData } from "@/src/hooks/api";
import { GenericSchema, GenericSchemaAsync } from "valibot";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Button as KButton } from "@kobalte/core/button";
import Icon from "@/src/components/Icon/Icon";
import "./SidebarSection.css";
import { Loader } from "../../components/Loader/Loader";
@@ -82,24 +83,24 @@ export function SidebarSectionForm<
</Typography>
<div class="controls h-4">
{editing() && !formStore.submitting && (
<Button
hierarchy="primary"
size="xs"
startIcon="Checkmark"
ghost
type="submit"
>
Save
</Button>
<KButton type="submit">
<Icon
icon="Checkmark"
color="tertiary"
size="0.75rem"
inverted
/>
</KButton>
)}
{editing() && formStore.submitting && <Loader />}
<Button
hierarchy="primary"
ghost
size="xs"
icon={editing() ? "Close" : "Edit"}
onClick={editOrClose}
/>
<KButton onClick={editOrClose}>
<Icon
icon={editing() ? "Close" : "Edit"}
color="tertiary"
size="0.75rem"
inverted
/>
</KButton>
</div>
</div>
<div class="content">

View File

@@ -2,6 +2,8 @@ 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",
@@ -16,24 +18,11 @@ export const Default: Story = {
args: {
children: (
<>
<ToolbarButton
name="select"
icon="Cursor"
description="Select my thing"
/>
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select this thing"
/>
<ToolbarButton name="select" icon="Cursor" />
<ToolbarButton name="new-machine" icon="NewMachine" />
<Divider orientation="vertical" />
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Add service"
/>
<ToolbarButton name="ai" icon="AI" description="Call your AI Manager" />
<ToolbarButton name="modules" icon="Modules" selected={true} />
<ToolbarButton name="ai" icon="AI" />
</>
),
},
@@ -51,22 +40,49 @@ export const WithTooltip: Story = {
args: {
children: (
<>
<ToolbarButton name="select" icon="Cursor" description="Select" />
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select"
/>
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Select"
/>
<ToolbarButton name="ai" icon="AI" description="Select" />
<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

@@ -3,26 +3,22 @@ import cx from "classnames";
import { Button } from "@kobalte/core/button";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import type { JSX } from "solid-js";
import { Tooltip } from "../Tooltip/Tooltip";
export interface ToolbarButtonProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
icon: IconVariant;
description: JSX.Element;
selected?: boolean;
}
export const ToolbarButton = (props: ToolbarButtonProps) => {
return (
<Tooltip description={props.description} gutter={10} placement="top">
<Button
class={cx(styles.toolbar_button, {
[styles["selected"]]: props.selected,
})}
{...props}
>
<Icon icon={props.icon} inverted={!props.selected} />
</Button>
</Tooltip>
<Button
class={cx(styles.toolbar_button, {
[styles["selected"]]: props.selected,
})}
{...props}
>
<Icon icon={props.icon} inverted={!props.selected} />
</Button>
);
};

View File

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

View File

@@ -1,40 +1,31 @@
import "./Tooltip.css";
import {
Tooltip as KTooltip,
TooltipRootProps as KTooltipRootProps,
} from "@kobalte/core/tooltip";
import cx from "classnames";
import { JSX } from "solid-js";
import styles from "./Tooltip.module.css";
import { Typography } from "../Typography/Typography";
export interface TooltipProps extends KTooltipRootProps {
inverted?: boolean;
trigger: JSX.Element;
children: JSX.Element;
description: JSX.Element;
animation?: "bounce";
}
export const Tooltip = (props: TooltipProps) => {
return (
<KTooltip {...props}>
<KTooltip.Trigger>{props.children}</KTooltip.Trigger>
<KTooltip.Trigger>{props.trigger}</KTooltip.Trigger>
<KTooltip.Portal>
<KTooltip.Content
class={cx(styles.tooltipContent, {
[styles.inverted]: props.inverted,
class={cx("tooltip-content", {
inverted: props.inverted,
"animate-bounce": props.animation == "bounce",
})}
>
{props.placement == "bottom" && <KTooltip.Arrow />}
<Typography
hierarchy="body"
size="s"
weight="medium"
color="primary"
inverted={!props.inverted}
>
{props.description}
</Typography>
{props.children}
{props.placement == "top" && <KTooltip.Arrow />}
</KTooltip.Content>
</KTooltip.Portal>

View File

@@ -36,9 +36,6 @@ export const navigateToClan = (navigate: Navigator, clanURI: string) => {
navigate(path);
};
export const navigateToOnboarding = (navigate: Navigator, addClan: boolean) =>
navigate(`/${addClan ? "?addClan=true" : ""}`);
export const navigateToMachine = (
navigate: Navigator,
clanURI: string,

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