Compare commits
188 Commits
vars-new
...
pr-4283-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a21d1bab | ||
|
|
28d5294292 | ||
|
|
3a52189ed6 | ||
|
|
5c33e02e24 | ||
|
|
2aa2145876 | ||
|
|
fa517e1149 | ||
|
|
3828a0cf49 | ||
|
|
75501a914b | ||
|
|
c9b8bdd6de | ||
|
|
5a4a7e9158 | ||
|
|
83ad0ae836 | ||
|
|
57163cf135 | ||
|
|
13185d005d | ||
|
|
18a6b57673 | ||
|
|
31f2c5106d | ||
|
|
4dfd151cd2 | ||
|
|
8ddd2b607e | ||
|
|
b4544b824a | ||
|
|
50122c2215 | ||
|
|
c65b35edc1 | ||
|
|
2eb6ee2264 | ||
|
|
ec1363aedf | ||
|
|
90495d4157 | ||
|
|
b8fa4b4677 | ||
|
|
ec8effcd46 | ||
|
|
09b8e2f49c | ||
|
|
7fefc8e3b2 | ||
|
|
62cadb8fbe | ||
|
|
f8748d021b | ||
|
|
b84df095a2 | ||
|
|
dbd48a54a3 | ||
|
|
3b2f0e2029 | ||
|
|
639d227055 | ||
|
|
7ac4d257d9 | ||
|
|
e45e809553 | ||
|
|
48c7613556 | ||
|
|
fe89d954da | ||
|
|
b8604d334b | ||
|
|
50cbe3c825 | ||
|
|
acab3b8905 | ||
|
|
eb6166796c | ||
|
|
6d8fd42faa | ||
|
|
494830326d | ||
|
|
edfad04305 | ||
|
|
81d3437ff7 | ||
|
|
d656167cf2 | ||
|
|
49e83954c5 | ||
|
|
8d1e0353f8 | ||
|
|
05658589a0 | ||
|
|
809a115e58 | ||
|
|
79d8d0707b | ||
|
|
b2179c9293 | ||
|
|
e33af96705 | ||
|
|
14a221d1d1 | ||
|
|
9f9ab3de19 | ||
|
|
9739a5ae2b | ||
|
|
54446d751f | ||
|
|
7bc8e091a5 | ||
|
|
3462d458ac | ||
|
|
bd42d67b0c | ||
|
|
d99ca36f9f | ||
|
|
57f9cd9eee | ||
|
|
a9ec94b0df | ||
|
|
c64dbceceb | ||
|
|
5d924e0c98 | ||
|
|
6a6688019b | ||
|
|
f33172fa73 | ||
|
|
00914311a4 | ||
|
|
ceeb40d9ac | ||
|
|
afab33056e | ||
|
|
a5183f4b4c | ||
|
|
a686d7523b | ||
|
|
56b784992d | ||
|
|
5f723dc376 | ||
|
|
1609989734 | ||
|
|
0c07d5cfe0 | ||
|
|
9c37ef4cbe | ||
|
|
783b6a8b06 | ||
|
|
4f13049ee2 | ||
|
|
2f4f303048 | ||
|
|
d02868b950 | ||
|
|
4f7d82671f | ||
|
|
0dce3fc7ec | ||
|
|
a635f9c6fe | ||
|
|
a8ed1c30e4 | ||
|
|
c0c41d52bd | ||
|
|
bb236bb543 | ||
|
|
d7cf79faa7 | ||
|
|
dab11cb020 | ||
|
|
f2cb6fef41 | ||
|
|
655b87ad04 | ||
|
|
d462ae501e | ||
|
|
59a8c402ba | ||
|
|
3b309ea74b | ||
|
|
508cd3c784 | ||
|
|
2bff7403df | ||
|
|
b5a6e809d0 | ||
|
|
ec28c5c307 | ||
|
|
10f9e5d11b | ||
|
|
b8ba8b79ca | ||
|
|
fd07d02d2d | ||
|
|
2a3d1efc6f | ||
|
|
947e0a5488 | ||
|
|
57b5520143 | ||
|
|
9fd1031f4d | ||
|
|
c382e8f1f3 | ||
|
|
cf92303f31 | ||
|
|
80d0dc9805 | ||
|
|
4e2cbb188c | ||
|
|
eb6460fb40 | ||
|
|
155bd36d2b | ||
|
|
40ea5bf591 | ||
|
|
0cd9c84de0 | ||
|
|
e1ea44a2cc | ||
|
|
7c4865e8b0 | ||
|
|
b032cd4a29 | ||
|
|
61edc1e06f | ||
|
|
c369f3b5d1 | ||
|
|
0cc1f072f7 | ||
|
|
a2a011a47f | ||
|
|
e1796e19e4 | ||
|
|
972adc7a7c | ||
|
|
e1b4f296e3 | ||
|
|
1cb2156d87 | ||
|
|
84703fa293 | ||
|
|
0e10122d54 | ||
|
|
ecd731024c | ||
|
|
e0da575201 | ||
|
|
3577c689bd | ||
|
|
885103bfa4 | ||
|
|
afc1ca37bd | ||
|
|
4aa536a1bf | ||
|
|
c61dfbf8dd | ||
|
|
e6785fa1d0 | ||
|
|
89ea01fd04 | ||
|
|
a8a08e21e4 | ||
|
|
700f571598 | ||
|
|
08c15b3d9b | ||
|
|
2848b6d5d6 | ||
|
|
ddc1059799 | ||
|
|
b690515dd7 | ||
|
|
e9cef9c7c1 | ||
|
|
ca69864a20 | ||
|
|
5436f284fb | ||
|
|
00df032635 | ||
|
|
a2c016718a | ||
|
|
d1abebf068 | ||
|
|
9635fb03b7 | ||
|
|
f48c596617 | ||
|
|
0589c71601 | ||
|
|
a2c2d73e49 | ||
|
|
99b22dfcbf | ||
|
|
cd04686663 | ||
|
|
2b3e847c28 | ||
|
|
d0ec4fd8e6 | ||
|
|
bb5c523ac8 | ||
|
|
4df4f5220b | ||
|
|
a082fd2ed9 | ||
|
|
3161c10aa8 | ||
|
|
7ad8ed1af0 | ||
|
|
94919dc9b8 | ||
|
|
1502cfa4a7 | ||
|
|
cce0207225 | ||
|
|
38f98645ac | ||
|
|
74d2ae0619 | ||
|
|
c122201ff2 | ||
|
|
e72795904d | ||
|
|
32ddb4ffa7 | ||
|
|
db6220b57b | ||
|
|
e929f36f80 | ||
|
|
f71460c4f9 | ||
|
|
8302f3ffde | ||
|
|
bd82de6001 | ||
|
|
06613de825 | ||
|
|
76af63ee1c | ||
|
|
3baa43fd87 | ||
|
|
a6b8ca06ab | ||
|
|
f7faf2cd63 | ||
|
|
bff3908bb1 | ||
|
|
d0613b4030 | ||
|
|
52b711667e | ||
|
|
13d6db98d1 | ||
|
|
195134dd5e | ||
|
|
0670f0ad32 | ||
|
|
daf843eeab | ||
|
|
291b742fd7 | ||
|
|
f7d6c23aaa | ||
|
|
1f26135381 |
75
.gitea/workflows/create-pr.sh
Executable file
75
.gitea/workflows/create-pr.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared script for creating pull requests in Gitea workflows
|
||||
set -euo pipefail
|
||||
|
||||
# Required environment variables:
|
||||
# - CI_BOT_TOKEN: Gitea bot token for authentication
|
||||
# - PR_BRANCH: Branch name for the pull request
|
||||
# - PR_TITLE: Title of the pull request
|
||||
# - PR_BODY: Body/description of the pull request
|
||||
|
||||
if [[ -z "${CI_BOT_TOKEN:-}" ]]; then
|
||||
echo "Error: CI_BOT_TOKEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_BRANCH:-}" ]]; then
|
||||
echo "Error: PR_BRANCH is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_TITLE:-}" ]]; then
|
||||
echo "Error: PR_TITLE is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${PR_BODY:-}" ]]; then
|
||||
echo "Error: PR_BODY is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Push the branch
|
||||
git push origin "+HEAD:${PR_BRANCH}"
|
||||
|
||||
# Create pull request
|
||||
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
|
||||
-H "Authorization: token $CI_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"head\": \"${PR_BRANCH}\",
|
||||
\"base\": \"main\",
|
||||
\"title\": \"${PR_TITLE}\",
|
||||
\"body\": \"${PR_BODY}\"
|
||||
}" \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
|
||||
|
||||
pr_number=$(echo "$resp" | jq -r '.number')
|
||||
|
||||
if [[ "$pr_number" == "null" ]]; then
|
||||
echo "Error creating pull request:" >&2
|
||||
echo "$resp" | jq . >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created pull request #$pr_number"
|
||||
|
||||
# Merge when checks succeed
|
||||
while true; do
|
||||
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
|
||||
-H "Authorization: token $CI_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"Do": "merge",
|
||||
"merge_when_checks_succeed": true,
|
||||
"delete_branch_after_merge": true
|
||||
}' \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
|
||||
msg=$(echo "$resp" | jq -r '.message')
|
||||
if [[ "$msg" != "Please try again later" ]]; then
|
||||
break
|
||||
fi
|
||||
echo "Retrying in 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Pull request #$pr_number merge initiated"
|
||||
@@ -19,35 +19,10 @@ jobs:
|
||||
run: |
|
||||
export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol
|
||||
git commit -am "Update pinned clan-core for checks"
|
||||
git push origin +HEAD:update-clan-core-for-checks
|
||||
set -x
|
||||
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
|
||||
-H "Authorization: token $CI_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"head": "update-clan-core-for-checks",
|
||||
"base": "main",
|
||||
"title": "Update Clan Core for Checks",
|
||||
"body": "This PR updates the pinned clan-core flake input that is used for checks."
|
||||
}' \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
|
||||
pr_number=$(echo "$resp" | jq -r '.number')
|
||||
|
||||
# Merge when succeed
|
||||
while true; do
|
||||
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
|
||||
-H "Authorization: token $CI_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"Do": "merge",
|
||||
"merge_when_checks_succeed": true,
|
||||
"delete_branch_after_merge": true
|
||||
}' \
|
||||
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
|
||||
msg=$(echo $resp | jq -r '.message')
|
||||
if [[ "$msg" != "Please try again later" ]]; then
|
||||
break
|
||||
fi
|
||||
echo "Retrying in 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
# Use shared PR creation script
|
||||
export PR_BRANCH="update-clan-core-for-checks"
|
||||
export PR_TITLE="Update Clan Core for Checks"
|
||||
export PR_BODY="This PR updates the pinned clan-core flake input that is used for checks."
|
||||
|
||||
./.gitea/workflows/create-pr.sh
|
||||
|
||||
40
.gitea/workflows/update-private-flake-inputs.yml
Normal file
40
.gitea/workflows/update-private-flake-inputs.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: "Update private flake inputs"
|
||||
on:
|
||||
repository_dispatch:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # Run daily at 3 AM
|
||||
jobs:
|
||||
update-private-flake:
|
||||
runs-on: nix
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Update private flake inputs
|
||||
run: |
|
||||
# Update the private flake lock file
|
||||
cd devFlake/private
|
||||
nix flake update
|
||||
cd ../..
|
||||
|
||||
# Update the narHash
|
||||
bash ./devFlake/update-private-narhash
|
||||
- name: Create pull request
|
||||
env:
|
||||
CI_BOT_TOKEN: ${{ secrets.CI_BOT_TOKEN }}
|
||||
run: |
|
||||
export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol
|
||||
|
||||
# Check if there are any changes
|
||||
if ! git diff --quiet; then
|
||||
git add devFlake/private/flake.lock devFlake/private.narHash
|
||||
git commit -m "Update dev flake"
|
||||
|
||||
# Use shared PR creation script
|
||||
export PR_BRANCH="update-dev-flake"
|
||||
export PR_TITLE="Update dev flake"
|
||||
export PR_BODY="This PR updates the dev flake inputs and corresponding narHash."
|
||||
else
|
||||
echo "No changes detected in dev flake inputs"
|
||||
fi
|
||||
@@ -19,10 +19,11 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
dependencies = [
|
||||
self
|
||||
pkgs.stdenv.drvPath
|
||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||
dependencies =
|
||||
[
|
||||
pkgs.stdenv.drvPath
|
||||
]
|
||||
++ builtins.map (i: i.outPath) (builtins.attrValues (builtins.removeAttrs self.inputs [ "self" ]));
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
in
|
||||
{
|
||||
|
||||
@@ -47,14 +47,6 @@ nixosLib.runTest (
|
||||
|
||||
clientone =
|
||||
{ config, pkgs, ... }:
|
||||
let
|
||||
dependencies = [
|
||||
clan-core
|
||||
pkgs.stdenv.drvPath
|
||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues clan-core.inputs);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
in
|
||||
{
|
||||
|
||||
services.openssh.enable = true;
|
||||
@@ -65,15 +57,6 @@ nixosLib.runTest (
|
||||
|
||||
environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ];
|
||||
|
||||
environment.etc.install-closure.source = "${closureInfo}/store-paths";
|
||||
nix.settings = {
|
||||
substituters = pkgs.lib.mkForce [ ];
|
||||
hashed-mirrors = null;
|
||||
connect-timeout = pkgs.lib.mkForce 3;
|
||||
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
|
||||
};
|
||||
system.extraDependencies = dependencies;
|
||||
|
||||
clan.core.state.test-backups.folders = [ "/var/test-backups" ];
|
||||
};
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ in
|
||||
pkgs.xkcdpass
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/borgbackup.ssh
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/borgbackup.ssh
|
||||
xkcdpass -n 4 -d - > "$out"/borgbackup.repokey
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ The importer module allows users to configure importing modules in a flexible an
|
||||
|
||||
It exposes the `extraModules` functionality of the inventory, without any added configuration.
|
||||
|
||||
## Usage:
|
||||
## Usage
|
||||
|
||||
```nix
|
||||
inventory.services = {
|
||||
|
||||
@@ -54,7 +54,7 @@ in
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/ssh.id_ed25519
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/ssh.id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ in
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t rsa -b 4096 -N "" -f "$out"/ssh.id_rsa
|
||||
ssh-keygen -t rsa -b 4096 -N "" -C "" -f "$out"/ssh.id_rsa
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/id_ed25519
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
pkgs.xkcdpass
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/borgbackup.ssh
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/borgbackup.ssh
|
||||
xkcdpass -n 4 -d - > "$out"/borgbackup.repokey
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -41,14 +41,6 @@
|
||||
clan-core,
|
||||
...
|
||||
}:
|
||||
let
|
||||
dependencies = [
|
||||
clan-core
|
||||
pkgs.stdenv.drvPath
|
||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues clan-core.inputs);
|
||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||
|
||||
in
|
||||
{
|
||||
|
||||
services.openssh.enable = true;
|
||||
@@ -59,15 +51,6 @@
|
||||
|
||||
environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ];
|
||||
|
||||
environment.etc.install-closure.source = "${closureInfo}/store-paths";
|
||||
nix.settings = {
|
||||
substituters = pkgs.lib.mkForce [ ];
|
||||
hashed-mirrors = null;
|
||||
connect-timeout = pkgs.lib.mkForce 3;
|
||||
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
|
||||
};
|
||||
system.extraDependencies = dependencies;
|
||||
|
||||
clan.core.state.test-backups.folders = [ "/var/test-backups" ];
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,13 @@ in
|
||||
unit-test-module = (
|
||||
self.clanLib.test.flakeModules.makeEvalChecks {
|
||||
inherit module;
|
||||
inherit self inputs;
|
||||
inherit inputs;
|
||||
fileset = lib.fileset.unions [
|
||||
# The hello-world service being tested
|
||||
../../clanServices/hello-world
|
||||
# Required modules
|
||||
../../nixosModules/clanCore
|
||||
];
|
||||
testName = "hello-world";
|
||||
tests = ./tests/eval-tests.nix;
|
||||
# Optional arguments passed to the test
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
The importer module allows users to configure importing modules in a flexible and structured way.
|
||||
It exposes the `extraModules` functionality of the inventory, without any added configuration.
|
||||
|
||||
## Usage:
|
||||
## Usage
|
||||
|
||||
```nix
|
||||
inventory.instances = {
|
||||
|
||||
36
clanServices/sshd/README.md
Normal file
36
clanServices/sshd/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
The `sshd` Clan service manages SSH to make it easy to securely access your machines over the internet. The service uses `vars` to store the SSH host keys for each machine to ensure they remain stable across deployments.
|
||||
|
||||
`sshd` also generates SSH certificates for both servers and clients allowing for certificate-based authentication for SSH.
|
||||
|
||||
The service also disables password-based authentication over SSH, to access your machines you'll need to use public key authentication or certificate-based authentication.
|
||||
|
||||
## Usage
|
||||
|
||||
```nix
|
||||
{
|
||||
inventory.instances = {
|
||||
# By default this service only generates ed25519 host keys
|
||||
sshd-basic = {
|
||||
module = {
|
||||
name = "sshd";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.server.tags.all = { };
|
||||
roles.client.tags.all = { };
|
||||
};
|
||||
|
||||
# Also generate RSA host keys for all servers
|
||||
sshd-with-rsa = {
|
||||
module = {
|
||||
name = "sshd";
|
||||
input = "clan-core";
|
||||
};
|
||||
roles.server.tags.all = { };
|
||||
roles.server.settings = {
|
||||
hostKeys.rsa.enable = true;
|
||||
};
|
||||
roles.client.tags.all = { };
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
_class = "clan.service";
|
||||
manifest.name = "clan-core/sshd";
|
||||
manifest.description = "Enables secure remote access to the machine over ssh.";
|
||||
manifest.description = "Enables secure remote access to the machine over SSH";
|
||||
manifest.categories = [
|
||||
"System"
|
||||
"Network"
|
||||
@@ -49,7 +49,7 @@
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/id_ed25519
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/id_ed25519
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t rsa -b 4096 -N "" -f "$out"/ssh.id_rsa
|
||||
ssh-keygen -t rsa -b 4096 -N "" -C "" -f "$out"/ssh.id_rsa
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
pkgs.openssh
|
||||
];
|
||||
script = ''
|
||||
ssh-keygen -t ed25519 -N "" -f "$out"/ssh.id_ed25519
|
||||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/ssh.id_ed25519
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
## Usage
|
||||
|
||||
```
|
||||
inventory.instances = {
|
||||
|
||||
# Deploy user alice on all machines. Don't prompt for password (will be
|
||||
# auto-generated).
|
||||
|
||||
user-alice = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
```nix
|
||||
{
|
||||
inventory.instances = {
|
||||
# Deploy user alice on all machines. Don't prompt for password (will be
|
||||
# auto-generated).
|
||||
user-alice = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
};
|
||||
roles.default.tags.all = { };
|
||||
roles.default.settings = {
|
||||
user = "alice";
|
||||
prompt = false;
|
||||
};
|
||||
};
|
||||
roles.default.tags.all = { };
|
||||
roles.default.settings = {
|
||||
user = "alice";
|
||||
prompt = false;
|
||||
|
||||
# Deploy user bob only on his laptop. Prompt for a password.
|
||||
user-bob = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
};
|
||||
roles.default.machines.bobs-laptop = { };
|
||||
roles.default.settings.user = "bob";
|
||||
};
|
||||
};
|
||||
|
||||
# Deploy user bob only on his laptop. Prompt for a password.
|
||||
user-bob = {
|
||||
module = {
|
||||
name = "users";
|
||||
input = "clan";
|
||||
};
|
||||
roles.default.machines.bobs-laptop = { };
|
||||
roles.default.settings.user = "bob";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,7 +15,15 @@ in
|
||||
unit-test-module = (
|
||||
self.clanLib.test.flakeModules.makeEvalChecks {
|
||||
inherit module;
|
||||
inherit self inputs;
|
||||
inherit inputs;
|
||||
fileset = lib.fileset.unions [
|
||||
# The zerotier service being tested
|
||||
../../clanServices/zerotier
|
||||
# Required modules
|
||||
../../nixosModules/clanCore
|
||||
# Dependencies like clan-cli
|
||||
../../pkgs/clan-cli
|
||||
];
|
||||
testName = "zerotier";
|
||||
tests = ./tests/eval-tests.nix;
|
||||
testArgs = { };
|
||||
|
||||
1
devFlake/private.narHash
Normal file
1
devFlake/private.narHash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-pFUj3KhQ4FkzZT19t+FHBru8u8Lspax0rS2cv7nXIgM=
|
||||
165
devFlake/private/flake.lock
generated
Normal file
165
devFlake/private/flake.lock
generated
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": [
|
||||
"systems"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ixx": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"nuschtos",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nuschtos",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748294338,
|
||||
"narHash": "sha256-FVO01jdmUNArzBS7NmaktLdGA5qA3lUMJ4B7a05Iynw=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "ixx",
|
||||
"rev": "cc5f390f7caf265461d4aab37e98d2292ebbdb85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NuschtOS",
|
||||
"ref": "v0.0.8",
|
||||
"repo": "ixx",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1751867001,
|
||||
"narHash": "sha256-3I49W0s3WVEDBO5S1RxYr74E2LLG7X8Wuvj9AmU0RDk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "73feb5e20ec7259e280ca6f424ba165059b3bb6b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable-small",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nuschtos": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"ixx": "ixx",
|
||||
"nixpkgs": [
|
||||
"nixpkgs-dev"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1749730855,
|
||||
"narHash": "sha256-L3x2nSlFkXkM6tQPLJP3oCBMIsRifhIDPMQQdHO5xWo=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"rev": "8dfe5879dd009ff4742b668d9c699bc4b9761742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs-dev": "nixpkgs-dev",
|
||||
"nuschtos": "nuschtos",
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": []
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750931469,
|
||||
"narHash": "sha256-0IEdQB1nS+uViQw4k3VGUXntjkDp7aAlqcxdewb/hAc=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "ac8e6f32e11e9c7f153823abc3ab007f2a65d3e1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
19
devFlake/private/flake.nix
Normal file
19
devFlake/private/flake.nix
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
description = "private dev inputs";
|
||||
|
||||
# Dev dependencies
|
||||
inputs.nixpkgs-dev.url = "github:NixOS/nixpkgs/nixos-unstable-small";
|
||||
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
inputs.flake-utils.inputs.systems.follows = "systems";
|
||||
|
||||
inputs.nuschtos.url = "github:NuschtOS/search";
|
||||
inputs.nuschtos.inputs.nixpkgs.follows = "nixpkgs-dev";
|
||||
|
||||
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||
inputs.treefmt-nix.inputs.nixpkgs.follows = "";
|
||||
|
||||
inputs.systems.url = "github:nix-systems/default";
|
||||
|
||||
outputs = _: { };
|
||||
}
|
||||
12
devFlake/update-private-narhash
Executable file
12
devFlake/update-private-narhash
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Used to update the private dev flake hash reference.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Updating $PWD/private.narHash" >&2
|
||||
|
||||
nix --extra-experimental-features 'flakes nix-command' flake lock ./private
|
||||
nix --extra-experimental-features 'flakes nix-command' hash path ./private >./private.narHash
|
||||
|
||||
echo OK
|
||||
@@ -62,6 +62,7 @@ nav:
|
||||
- Vars Backend: guides/vars-backend.md
|
||||
- Facts Backend: guides/secrets.md
|
||||
- Adding more machines: guides/more-machines.md
|
||||
- Target Host: guides/target-host.md
|
||||
- Inventory:
|
||||
- Inventory: guides/inventory.md
|
||||
- Secure Boot: guides/secure-boot.md
|
||||
@@ -154,6 +155,7 @@ nav:
|
||||
- reference/cli/show.md
|
||||
- reference/cli/ssh.md
|
||||
- reference/cli/state.md
|
||||
- reference/cli/templates.md
|
||||
- reference/cli/vars.md
|
||||
- reference/cli/vms.md
|
||||
- NixOS Modules:
|
||||
@@ -197,7 +199,6 @@ theme:
|
||||
- navigation.instant
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.footer
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
clan-core,
|
||||
pkgs,
|
||||
module-docs,
|
||||
clan-cli-docs,
|
||||
@@ -19,7 +18,17 @@ pkgs.stdenv.mkDerivation {
|
||||
|
||||
# Points to repository root.
|
||||
# so that we can access directories outside of docs to include code snippets
|
||||
src = clan-core;
|
||||
src = pkgs.lib.fileset.toSource {
|
||||
root = ../..;
|
||||
fileset = pkgs.lib.fileset.unions [
|
||||
# Docs directory
|
||||
../../docs
|
||||
# Icons needed for the build
|
||||
../../pkgs/clan-app/ui/icons
|
||||
# Any other directories that might be referenced for code snippets
|
||||
# Add them here as needed based on what mkdocs actually uses
|
||||
];
|
||||
};
|
||||
|
||||
nativeBuildInputs =
|
||||
[
|
||||
|
||||
@@ -82,10 +82,9 @@
|
||||
}
|
||||
''
|
||||
export CLAN_CORE_PATH=${
|
||||
self.filter {
|
||||
include = [
|
||||
"clanModules"
|
||||
];
|
||||
inputs.nixpkgs.lib.fileset.toSource {
|
||||
root = ../..;
|
||||
fileset = ../../clanModules;
|
||||
}
|
||||
}
|
||||
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
|
||||
@@ -126,7 +125,6 @@
|
||||
});
|
||||
packages = {
|
||||
docs = pkgs.python3.pkgs.callPackage ./default.nix {
|
||||
clan-core = self;
|
||||
inherit (self'.packages)
|
||||
clan-cli-docs
|
||||
docs-options
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
{ self, config, ... }:
|
||||
{
|
||||
self,
|
||||
config,
|
||||
inputs,
|
||||
privateInputs ? { },
|
||||
...
|
||||
}:
|
||||
{
|
||||
perSystem =
|
||||
{
|
||||
inputs',
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
@@ -157,11 +163,16 @@
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.docs-options = inputs'.nuschtos.packages.mkMultiSearch {
|
||||
inherit baseHref;
|
||||
title = "Clan Options";
|
||||
# scopes = mapAttrsToList mkScope serviceModules;
|
||||
scopes = [ (mkScope "Clan Inventory" serviceModules) ];
|
||||
packages = lib.optionalAttrs ((privateInputs ? nuschtos) || (inputs ? nuschtos)) {
|
||||
docs-options =
|
||||
(privateInputs.nuschtos or inputs.nuschtos)
|
||||
.packages.${pkgs.stdenv.hostPlatform.system}.mkMultiSearch
|
||||
{
|
||||
inherit baseHref;
|
||||
title = "Clan Options";
|
||||
# scopes = mapAttrsToList mkScope serviceModules;
|
||||
scopes = [ (mkScope "Clan Inventory" serviceModules) ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,7 +452,6 @@ Each `clanService`:
|
||||
* Is a module of class **`clan.service`**
|
||||
* Can define **roles** (e.g., `client`, `server`)
|
||||
* Uses **`inventory.instances`** to configure where and how it is deployed
|
||||
* Replaces the legacy `clanModules` and `inventory.services` system altogether
|
||||
|
||||
!!! Note
|
||||
`clanServices` are part of Clan's next-generation service model and are intended to replace `clanModules`.
|
||||
|
||||
@@ -28,7 +28,7 @@ Benefits:
|
||||
* Caching mechanism is very simple.
|
||||
|
||||
|
||||
### Method 2: Direct access:
|
||||
### Method 2: Direct access
|
||||
|
||||
Directly calling the evaluator / build sandbox via `nix build` and `nix eval`within the Python code
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ clanModules/borgbackup
|
||||
|
||||
```nix title="flake.nix"
|
||||
# ...
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan-core.lib.clan {
|
||||
# 1. Add the module to the available clanModules with inventory support
|
||||
inventory.modules = {
|
||||
@@ -175,6 +176,7 @@ The following shows how to add options to your module.
|
||||
Configuration can be set as follows.
|
||||
|
||||
```nix title="flake.nix"
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan-core.lib.clan {
|
||||
inventory.services = {
|
||||
custom-module.instance_1 = {
|
||||
|
||||
@@ -27,6 +27,7 @@ i.e. `@hsjobeki/customNetworking`
|
||||
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } ({
|
||||
imports = [ inputs.clan-core.flakeModules.default ];
|
||||
# ...
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
# If needed: Exporting the module for other people
|
||||
modules."@hsjobeki/customNetworking" = import ./service-modules/networking.nix;
|
||||
@@ -218,6 +219,7 @@ To import the module use `importApply`
|
||||
outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}: {
|
||||
imports = [ inputs.clan-core.flakeModules.default ];
|
||||
# ...
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
# Register the module
|
||||
modules."@hsjobeki/messaging" = lib.importApply ./service-modules/messaging.nix { inherit self; };
|
||||
@@ -244,6 +246,7 @@ Then wrap the module and forward the variable `self` from the outer context into
|
||||
outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}: {
|
||||
imports = [ inputs.clan-core.flakeModules.default ];
|
||||
# ...
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
# Register the module
|
||||
modules."@hsjobeki/messaging" = {
|
||||
|
||||
@@ -105,7 +105,7 @@ git+file:///home/lhebendanz/Projects/clan-core
|
||||
│ ├───editor omitted (use '--all-systems' to show)
|
||||
└───templates
|
||||
├───default: template: Initialize a new clan flake
|
||||
└───new-clan: template: Initialize a new clan flake
|
||||
└───default: template: Initialize a new clan flake
|
||||
```
|
||||
|
||||
You can execute every test separately by following the tree path `nix run .#checks.x86_64-linux.clan-pytest -L` for example.
|
||||
|
||||
@@ -122,8 +122,8 @@ CTRL+D
|
||||
4. Locally generate ssh host keys. You only need to generate ones for the algorithms you're using in `authorizedKeys`.
|
||||
|
||||
```bash
|
||||
ssh-keygen -q -N "" -t ed25519 -f ./initrd_host_ed25519_key
|
||||
ssh-keygen -q -N "" -t rsa -b 4096 -f ./initrd_host_rsa_key
|
||||
ssh-keygen -q -N "" -C "" -t ed25519 -f ./initrd_host_ed25519_key
|
||||
ssh-keygen -q -N "" -C "" -t rsa -b 4096 -f ./initrd_host_rsa_key
|
||||
```
|
||||
|
||||
5. Securely copy your local initrd ssh host keys to the installer's `/mnt` directory:
|
||||
|
||||
@@ -90,6 +90,7 @@ See the complete [list](../../guides/more-machines.md#automatic-registration) of
|
||||
The option: `machines.<name>` is used to add extra *nixosConfiguration* to a machine
|
||||
|
||||
```{.nix .annotate title="flake.nix" hl_lines="3-13 18-22"}
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
inventory.machines = {
|
||||
jon = {
|
||||
|
||||
@@ -28,6 +28,7 @@ To learn more: [Guide about clanService](../clanServices.md)
|
||||
inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [ inputs.clan-core.flakeModules.default ];
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
inventory.machines = {
|
||||
jon = {
|
||||
@@ -76,6 +77,7 @@ Adding the following services is recommended for most users:
|
||||
inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [ inputs.clan-core.flakeModules.default ];
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = {
|
||||
inventory.machines = {
|
||||
jon = {
|
||||
|
||||
@@ -103,10 +103,15 @@ Don’t worry if your output looks different—the template evolves over time.
|
||||
run `nix develop` every time, we recommend setting up [direnv](https://direnv.net/).
|
||||
|
||||
```
|
||||
clan machines list
|
||||
clan show
|
||||
```
|
||||
|
||||
If you see no output yet, that’s expected — [add machines](./add-machines.md) to populate it.
|
||||
You should see something like this:
|
||||
|
||||
```terminal-session
|
||||
Name: my-clan
|
||||
Description: None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ are loaded when using Clan:
|
||||
outputs =
|
||||
{ self, clan-core, ... }:
|
||||
let
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = clan-core.lib.clan {
|
||||
inherit self;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ For the purpose of this guide we have two machines:
|
||||
outputs =
|
||||
{ self, clan-core, ... }:
|
||||
let
|
||||
# Sometimes this attribute set is defined in clan.nix
|
||||
clan = clan-core.lib.clan {
|
||||
inherit self;
|
||||
|
||||
|
||||
84
docs/site/guides/target-host.md
Normal file
84
docs/site/guides/target-host.md
Normal 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 doesn’t 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 machine’s 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 it’s 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
|
||||
|
||||
We’re working on a new networking module that will automatically do all of this for you.
|
||||
|
||||
- Easier to use
|
||||
- Sane defaults: You’ll 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.
|
||||
92
flake.lock
generated
92
flake.lock
generated
@@ -16,11 +16,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1751413887,
|
||||
"narHash": "sha256-+ut7DrSwamExIvaCFdiTYD88NTSYJFG2CEOvCha59vI=",
|
||||
"rev": "246f0d66547d073af6249e4f7852466197e871ed",
|
||||
"lastModified": 1751846468,
|
||||
"narHash": "sha256-h0mpWZIOIAKj4fmLNyI2HDG+c0YOkbYmyJXSj/bQ9s0=",
|
||||
"rev": "a2166c13b0cb3febdaf36391cd2019aa2ccf4366",
|
||||
"type": "tarball",
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/246f0d66547d073af6249e4f7852466197e871ed.tar.gz"
|
||||
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/a2166c13b0cb3febdaf36391cd2019aa2ccf4366.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -34,11 +34,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1751607816,
|
||||
"narHash": "sha256-5PtrwjqCIJ4DKQhzYdm8RFePBuwb+yTzjV52wWoGSt4=",
|
||||
"lastModified": 1751854533,
|
||||
"narHash": "sha256-U/OQFplExOR1jazZY4KkaQkJqOl59xlh21HP9mI79Vc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "da6109c917b48abc1f76dd5c9bf3901c8c80f662",
|
||||
"rev": "16b74a1e304197248a1bc663280f2548dbfcae3c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -67,52 +67,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": [
|
||||
"systems"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ixx": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"nuschtos",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nuschtos",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748294338,
|
||||
"narHash": "sha256-FVO01jdmUNArzBS7NmaktLdGA5qA3lUMJ4B7a05Iynw=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "ixx",
|
||||
"rev": "cc5f390f7caf265461d4aab37e98d2292ebbdb85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NuschtOS",
|
||||
"ref": "v0.0.8",
|
||||
"repo": "ixx",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -164,51 +118,25 @@
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 315532800,
|
||||
"narHash": "sha256-0HRxGUoOMtOYnwlMWY0AkuU88WHaI3Q5GEILmsWpI8U=",
|
||||
"rev": "a48741b083d4f36dd79abd9f760c84da6b4dc0e5",
|
||||
"narHash": "sha256-mUlYenGbsUFP0A3EhfKJXmUl5+MQGJLhoEop2t3g5p4=",
|
||||
"rev": "ceb24d94c6feaa4e8737a8e2bd3cf71c3a7eaaa0",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre823094.a48741b083d4/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre826033.ceb24d94c6fe/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz"
|
||||
}
|
||||
},
|
||||
"nuschtos": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"ixx": "ixx",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1749730855,
|
||||
"narHash": "sha256-L3x2nSlFkXkM6tQPLJP3oCBMIsRifhIDPMQQdHO5xWo=",
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"rev": "8dfe5879dd009ff4742b668d9c699bc4b9761742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NuschtOS",
|
||||
"repo": "search",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"data-mesher": "data-mesher",
|
||||
"disko": "disko",
|
||||
"flake-parts": "flake-parts",
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-darwin": "nix-darwin",
|
||||
"nix-select": "nix-select",
|
||||
"nixos-facter-modules": "nixos-facter-modules",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nuschtos": "nuschtos",
|
||||
"sops-nix": "sops-nix",
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
|
||||
23
flake.nix
23
flake.nix
@@ -35,19 +35,13 @@
|
||||
};
|
||||
};
|
||||
|
||||
# dependencies needed for nuschtos
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
flake-utils.inputs.systems.follows = "systems";
|
||||
nuschtos.url = "github:NuschtOS/search";
|
||||
nuschtos.inputs.nixpkgs.follows = "nixpkgs";
|
||||
nuschtos.inputs.flake-utils.follows = "flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{
|
||||
flake-parts,
|
||||
nixpkgs,
|
||||
systems,
|
||||
flake-parts,
|
||||
...
|
||||
}:
|
||||
let
|
||||
@@ -56,10 +50,25 @@
|
||||
optional
|
||||
pathExists
|
||||
;
|
||||
|
||||
loadDevFlake =
|
||||
path:
|
||||
let
|
||||
flakeHash = nixpkgs.lib.fileContents "${toString path}.narHash";
|
||||
flakePath = "path:${toString path}?narHash=${flakeHash}";
|
||||
in
|
||||
builtins.getFlake (builtins.unsafeDiscardStringContext flakePath);
|
||||
|
||||
devFlake = builtins.tryEval (loadDevFlake ./devFlake/private);
|
||||
|
||||
privateInputs = if devFlake.success then devFlake.value.inputs else { };
|
||||
in
|
||||
flake-parts.lib.mkFlake { inherit inputs; } (
|
||||
{ ... }:
|
||||
{
|
||||
_module.args = {
|
||||
inherit privateInputs;
|
||||
};
|
||||
clan = {
|
||||
meta.name = "clan-core";
|
||||
inventory = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
perSystem =
|
||||
{ self', pkgs, ... }:
|
||||
{
|
||||
treefmt.projectRootFile = ".git/config";
|
||||
treefmt.projectRootFile = "LICENSE.md";
|
||||
treefmt.programs.shellcheck.enable = true;
|
||||
|
||||
treefmt.programs.mypy.enable = true;
|
||||
|
||||
@@ -37,6 +37,7 @@ lib.fix (
|
||||
inventory = clanLib.callLib ./modules/inventory { };
|
||||
modules = clanLib.callLib ./modules/inventory/frontmatter { };
|
||||
test = clanLib.callLib ./test { };
|
||||
flake-inputs = clanLib.callLib ./flake-inputs.nix { };
|
||||
# Custom types
|
||||
types = clanLib.callLib ./types { };
|
||||
|
||||
|
||||
18
lib/flake-inputs.nix
Normal file
18
lib/flake-inputs.nix
Normal file
@@ -0,0 +1,18 @@
|
||||
{ ... }:
|
||||
{
|
||||
/**
|
||||
Generate nix-unit input overrides for tests
|
||||
|
||||
# Example
|
||||
```nix
|
||||
inputOverrides = clanLib.flake-inputs.getOverrides inputs;
|
||||
```
|
||||
*/
|
||||
getOverrides =
|
||||
inputs:
|
||||
builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (
|
||||
builtins.filter (name: name != "self") (builtins.attrNames inputs)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
{ self, inputs, ... }:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
perSystem =
|
||||
|
||||
@@ -229,8 +229,6 @@ in
|
||||
clanInternals = {
|
||||
inventoryClass =
|
||||
let
|
||||
localModuleSet =
|
||||
lib.filterAttrs (n: _: !inventory._legacyModules ? ${n}) inventory.modules // config.modules;
|
||||
flakeInputs = config.self.inputs;
|
||||
in
|
||||
{
|
||||
@@ -240,7 +238,7 @@ in
|
||||
imports = [
|
||||
../inventoryClass/builder/default.nix
|
||||
(lib.modules.importApply ../inventoryClass/service-list-from-inputs.nix {
|
||||
inherit flakeInputs clanLib localModuleSet;
|
||||
inherit flakeInputs clanLib;
|
||||
})
|
||||
{
|
||||
inherit inventory directory;
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{ self, inputs, ... }:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
perSystem =
|
||||
@@ -12,6 +10,23 @@ in
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Common filtered source for inventory tests
|
||||
inventoryTestsSrc = lib.fileset.toSource {
|
||||
root = ../../../..;
|
||||
fileset = lib.fileset.unions [
|
||||
../../../../flake.nix
|
||||
../../../../flake.lock
|
||||
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../../..)
|
||||
../../../../flakeModules
|
||||
../../../../lib
|
||||
../../../../nixosModules/clanCore
|
||||
../../../../clanModules/borgbackup
|
||||
../../../../machines
|
||||
../../../../inventory.json
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.<attrName>
|
||||
legacyPackages.evalTests-distributedServices = import ./tests {
|
||||
@@ -29,7 +44,7 @@ in
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${self}#legacyPackages.${system}.evalTests-distributedServices
|
||||
--flake ${inventoryTestsSrc}#legacyPackages.${system}.evalTests-distributedServices
|
||||
|
||||
touch $out
|
||||
'';
|
||||
@@ -39,7 +54,7 @@ in
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${self}#legacyPackages.${system}.eval-tests-resolve-module
|
||||
--flake ${inventoryTestsSrc}#legacyPackages.${system}.eval-tests-resolve-module
|
||||
|
||||
touch $out
|
||||
'';
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
@@ -70,12 +68,18 @@ in
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${
|
||||
self.filter {
|
||||
include = [
|
||||
"flakeModules"
|
||||
"lib"
|
||||
"clanModules/flake-module.nix"
|
||||
"clanModules/borgbackup"
|
||||
lib.fileset.toSource {
|
||||
root = ../../..;
|
||||
fileset = lib.fileset.unions [
|
||||
../../../flake.nix
|
||||
../../../flake.lock
|
||||
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
|
||||
../../../flakeModules
|
||||
../../../lib
|
||||
../../../nixosModules/clanCore
|
||||
../../../clanModules/borgbackup
|
||||
../../../machines
|
||||
../../../inventory.json
|
||||
];
|
||||
}
|
||||
}#legacyPackages.${system}.evalTests-inventory
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
flakeInputs,
|
||||
clanLib,
|
||||
localModuleSet,
|
||||
}:
|
||||
{ lib, config, ... }:
|
||||
|
||||
let
|
||||
|
||||
inspectModule =
|
||||
inputName: moduleName: module:
|
||||
let
|
||||
@@ -28,16 +25,30 @@ in
|
||||
{
|
||||
options.modulesPerSource = lib.mkOption {
|
||||
# { sourceName :: { moduleName :: {} }}
|
||||
readOnly = true;
|
||||
type = lib.types.raw;
|
||||
default =
|
||||
let
|
||||
inputsWithModules = lib.filterAttrs (_inputName: v: v ? clan.modules) flakeInputs;
|
||||
|
||||
in
|
||||
lib.mapAttrs (
|
||||
inputName: v: lib.mapAttrs (inspectModule inputName) v.clan.modules
|
||||
) inputsWithModules;
|
||||
};
|
||||
options.localModules = lib.mkOption {
|
||||
default = lib.mapAttrs (inspectModule "self") localModuleSet;
|
||||
readOnly = true;
|
||||
type = lib.types.raw;
|
||||
default = config.modulesPerSource.self;
|
||||
};
|
||||
options.templatesPerSource = lib.mkOption {
|
||||
# { sourceName :: { moduleName :: {} }}
|
||||
readOnly = true;
|
||||
type = lib.types.raw;
|
||||
default =
|
||||
let
|
||||
inputsWithTemplates = lib.filterAttrs (_inputName: v: v ? clan.templates) flakeInputs;
|
||||
in
|
||||
lib.mapAttrs (_inputName: v: lib.mapAttrs (_n: t: t) v.clan.templates) inputsWithTemplates;
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
makeEvalChecks =
|
||||
{
|
||||
self,
|
||||
fileset,
|
||||
inputs,
|
||||
testName,
|
||||
tests,
|
||||
@@ -24,9 +24,7 @@
|
||||
testArgs ? { },
|
||||
}:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = clanLib.flake-inputs.getOverrides inputs;
|
||||
attrName = "eval-tests-${testName}";
|
||||
in
|
||||
{
|
||||
@@ -41,16 +39,44 @@
|
||||
}
|
||||
// testArgs
|
||||
);
|
||||
checks.${attrName} = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
checks.${attrName} =
|
||||
let
|
||||
# The root is two directories up from where this file is located
|
||||
root = ../..;
|
||||
|
||||
nix-unit --eval-store "$HOME" \
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${self}#legacyPackages.${system}.${attrName}
|
||||
touch $out
|
||||
'';
|
||||
# Combine the user-provided fileset with all flake-module.nix files
|
||||
# and other essential files
|
||||
src = lib.fileset.toSource {
|
||||
inherit root;
|
||||
fileset = lib.fileset.unions [
|
||||
# Core flake files
|
||||
(root + "/flake.nix")
|
||||
(root + "/flake.lock")
|
||||
|
||||
# All flake-module.nix files anywhere in the tree
|
||||
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") root)
|
||||
|
||||
# The flakeModules/clan.nix if it exists
|
||||
(lib.fileset.maybeMissing (root + "/flakeModules/clan.nix"))
|
||||
|
||||
# Core libraries
|
||||
(root + "/lib")
|
||||
|
||||
# User-provided fileset
|
||||
fileset
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
|
||||
nix-unit --eval-store "$HOME" \
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${src}#legacyPackages.${system}.${attrName}
|
||||
touch $out
|
||||
'';
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{ self, inputs, ... }:
|
||||
{
|
||||
self,
|
||||
inputs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
perSystem =
|
||||
{ ... }:
|
||||
@@ -10,7 +15,11 @@
|
||||
test-types-module = (
|
||||
self.clanLib.test.flakeModules.makeEvalChecks {
|
||||
module = throw "";
|
||||
inherit self inputs;
|
||||
inherit inputs;
|
||||
fileset = lib.fileset.unions [
|
||||
# Only lib is needed for type tests
|
||||
../../lib
|
||||
];
|
||||
testName = "types";
|
||||
tests = ./tests.nix;
|
||||
# Optional arguments passed to the test
|
||||
|
||||
@@ -40,6 +40,18 @@ in
|
||||
};
|
||||
|
||||
config = {
|
||||
# Check for removed passBackend option usage
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.clan.core.vars.settings.passBackend == null;
|
||||
message = ''
|
||||
The option `clan.core.vars.settings.passBackend' has been removed.
|
||||
Use clan.core.vars.password-store.passPackage instead.
|
||||
Set it to pkgs.pass for GPG or pkgs.passage for age encryption.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# check all that all non-secret files have no owner/group/mode set
|
||||
warnings = lib.foldl' (
|
||||
warnings: generator:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{ lib, pkgs, ... }:
|
||||
{ lib, pkgs }:
|
||||
let
|
||||
eval =
|
||||
module:
|
||||
|
||||
@@ -5,18 +5,14 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
|
||||
in
|
||||
{
|
||||
perSystem =
|
||||
{ system, pkgs, ... }:
|
||||
{
|
||||
legacyPackages.evalTests-module-clan-vars = import ./eval-tests {
|
||||
inherit lib;
|
||||
clan-core = self;
|
||||
pkgs = inputs.nixpkgs.legacyPackages.${system};
|
||||
inherit lib pkgs;
|
||||
};
|
||||
checks.eval-module-clan-vars = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
@@ -26,11 +22,15 @@ in
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${
|
||||
self.filter {
|
||||
include = [
|
||||
"flakeModules"
|
||||
"nixosModules"
|
||||
"lib"
|
||||
lib.fileset.toSource {
|
||||
root = ../../..;
|
||||
fileset = lib.fileset.unions [
|
||||
../../../flake.nix
|
||||
../../../flake.lock
|
||||
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
|
||||
../../../flakeModules/clan.nix
|
||||
../../../lib
|
||||
../../../nixosModules/clanCore/vars
|
||||
];
|
||||
}
|
||||
}#legacyPackages.${system}.evalTests-module-clan-vars
|
||||
|
||||
@@ -54,7 +54,7 @@ in
|
||||
{
|
||||
_class = "nixos";
|
||||
|
||||
options.clan.vars.password-store = {
|
||||
options.clan.core.vars.password-store = {
|
||||
secretLocation = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/etc/secret-vars";
|
||||
@@ -62,6 +62,13 @@ in
|
||||
location where the tarball with the password-store secrets will be uploaded to and the manifest
|
||||
'';
|
||||
};
|
||||
passPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.pass;
|
||||
description = ''
|
||||
Password store package to use. Can be pkgs.pass for GPG-based storage or pkgs.passage for age-based storage.
|
||||
'';
|
||||
};
|
||||
};
|
||||
config = {
|
||||
clan.core.vars.settings =
|
||||
@@ -76,7 +83,7 @@ in
|
||||
else if file.config.neededFor == "services" then
|
||||
"/run/secrets/${file.config.generatorName}/${file.config.name}"
|
||||
else if file.config.neededFor == "activation" then
|
||||
"${config.clan.password-store.secretLocation}/activation/${file.config.generatorName}/${file.config.name}"
|
||||
"${config.clan.core.vars.password-store.secretLocation}/activation/${file.config.generatorName}/${file.config.name}"
|
||||
else if file.config.neededFor == "partitioning" then
|
||||
"/run/partitioning-secrets/${file.config.generatorName}/${file.config.name}"
|
||||
else
|
||||
@@ -95,7 +102,7 @@ in
|
||||
]
|
||||
''
|
||||
[ -e /run/current-system ] || echo setting up secrets...
|
||||
${installSecretTarball}/bin/install-secret-tarball ${config.clan.vars.password-store.secretLocation}/secrets_for_users.tar.gz /run/user-secrets
|
||||
${installSecretTarball}/bin/install-secret-tarball ${config.clan.core.vars.password-store.secretLocation}/secrets_for_users.tar.gz /run/user-secrets
|
||||
''
|
||||
// lib.optionalAttrs (config.system ? dryActivationScript) {
|
||||
supportsDryActivation = true;
|
||||
@@ -111,7 +118,7 @@ in
|
||||
]
|
||||
''
|
||||
[ -e /run/current-system ] || echo setting up secrets...
|
||||
${installSecretTarball}/bin/install-secret-tarball ${config.clan.vars.password-store.secretLocation}/secrets.tar.gz /run/secrets
|
||||
${installSecretTarball}/bin/install-secret-tarball ${config.clan.core.vars.password-store.secretLocation}/secrets.tar.gz /run/secrets
|
||||
''
|
||||
// lib.optionalAttrs (config.system ? dryActivationScript) {
|
||||
supportsDryActivation = true;
|
||||
@@ -129,7 +136,7 @@ in
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = [
|
||||
"${installSecretTarball}/bin/install-secret-tarball ${config.clan.vars.password-store.secretLocation}/secrets_for_users.tar.gz /run/user-secrets"
|
||||
"${installSecretTarball}/bin/install-secret-tarball ${config.clan.core.vars.password-store.secretLocation}/secrets_for_users.tar.gz /run/user-secrets"
|
||||
];
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
@@ -142,7 +149,7 @@ in
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = [
|
||||
"${installSecretTarball}/bin/install-secret-tarball ${config.clan.vars.password-store.secretLocation}/secrets.tar.gz /run/secrets"
|
||||
"${installSecretTarball}/bin/install-secret-tarball ${config.clan.core.vars.password-store.secretLocation}/secrets.tar.gz /run/secrets"
|
||||
];
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
@@ -15,17 +15,6 @@
|
||||
'';
|
||||
};
|
||||
|
||||
passBackend = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"passage"
|
||||
"pass"
|
||||
];
|
||||
default = "pass";
|
||||
description = ''
|
||||
password-store backend to use. Valid options are `pass` and `passage`
|
||||
'';
|
||||
};
|
||||
|
||||
secretModule = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
internal = true;
|
||||
@@ -65,4 +54,15 @@
|
||||
the python import path to the public module
|
||||
'';
|
||||
};
|
||||
|
||||
# Legacy option that guides migration
|
||||
passBackend = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
visible = false;
|
||||
description = ''
|
||||
DEPRECATED: This option has been removed. Use clan.vars.password-store.passPackage instead.
|
||||
Set it to pkgs.pass for GPG or pkgs.passage for age encryption.
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../clan-cli/clan_lib"
|
||||
},
|
||||
{
|
||||
"path": "ui-2d"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -4,10 +4,10 @@ import sys
|
||||
|
||||
from clan_cli.profiler import profile
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from clan_app.app import ClanAppOptions, app_run
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@profile
|
||||
def main(argv: list[str] = sys.argv) -> int:
|
||||
|
||||
87
pkgs/clan-app/clan_app/api/api_bridge.py
Normal file
87
pkgs/clan-app/clan_app/api/api_bridge.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .middleware import Middleware
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendRequest:
|
||||
method_name: str
|
||||
args: dict[str, Any]
|
||||
header: dict[str, Any]
|
||||
op_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendResponse:
|
||||
body: Any
|
||||
header: dict[str, Any]
|
||||
_op_key: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiBridge(ABC):
|
||||
"""Generic interface for API bridges that can handle method calls from different sources."""
|
||||
|
||||
middleware_chain: tuple["Middleware", ...]
|
||||
|
||||
@abstractmethod
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the client."""
|
||||
|
||||
def process_request(self, request: BackendRequest) -> None:
|
||||
"""Process an API request through the middleware chain."""
|
||||
from .middleware import MiddlewareContext
|
||||
|
||||
with ExitStack() as stack:
|
||||
context = MiddlewareContext(
|
||||
request=request,
|
||||
bridge=self,
|
||||
exit_stack=stack,
|
||||
)
|
||||
|
||||
# Process through middleware chain
|
||||
for middleware in self.middleware_chain:
|
||||
try:
|
||||
log.debug(
|
||||
f"{middleware.__class__.__name__} => {request.method_name}"
|
||||
)
|
||||
middleware.process(context)
|
||||
except Exception as e:
|
||||
# If middleware fails, handle error
|
||||
self.send_error_response(
|
||||
request.op_key, str(e), ["middleware_error"]
|
||||
)
|
||||
return
|
||||
|
||||
def send_error_response(
|
||||
self, op_key: str, error_message: str, location: list[str]
|
||||
) -> None:
|
||||
"""Send an error response."""
|
||||
from clan_lib.api import ApiError, ErrorDataClass
|
||||
|
||||
error_data = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
errors=[
|
||||
ApiError(
|
||||
message="An internal error occured",
|
||||
description=error_message,
|
||||
location=location,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = BackendResponse(
|
||||
body=error_data,
|
||||
header={},
|
||||
_op_key=op_key,
|
||||
)
|
||||
|
||||
self.send_response(response)
|
||||
@@ -1,16 +1,17 @@
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
|
||||
from clan_lib.api import ApiError, ErrorDataClass, SuccessDataClass
|
||||
from clan_lib.api.directory import FileRequest
|
||||
from gi.repository import Gio, GLib, Gtk
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
14
pkgs/clan-app/clan_app/api/middleware/__init__.py
Normal file
14
pkgs/clan-app/clan_app/api/middleware/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Middleware components for the webview API bridge."""
|
||||
|
||||
from .argument_parsing import ArgumentParsingMiddleware
|
||||
from .base import Middleware, MiddlewareContext
|
||||
from .logging import LoggingMiddleware
|
||||
from .method_execution import MethodExecutionMiddleware
|
||||
|
||||
__all__ = [
|
||||
"ArgumentParsingMiddleware",
|
||||
"LoggingMiddleware",
|
||||
"MethodExecutionMiddleware",
|
||||
"Middleware",
|
||||
"MiddlewareContext",
|
||||
]
|
||||
55
pkgs/clan-app/clan_app/api/middleware/argument_parsing.py
Normal file
55
pkgs/clan-app/clan_app/api/middleware/argument_parsing.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from clan_lib.api import MethodRegistry, from_dict
|
||||
|
||||
from clan_app.api.api_bridge import BackendRequest
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArgumentParsingMiddleware(Middleware):
|
||||
"""Middleware that handles argument parsing and dataclass construction."""
|
||||
|
||||
api: MethodRegistry
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
try:
|
||||
# Convert dictionary arguments to dataclass instances
|
||||
reconciled_arguments = {}
|
||||
for k, v in context.request.args.items():
|
||||
if k == "op_key":
|
||||
continue
|
||||
|
||||
# Get the expected argument type from the API
|
||||
arg_class = self.api.get_method_argtype(context.request.method_name, k)
|
||||
|
||||
# Convert dictionary to dataclass instance
|
||||
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||
|
||||
# Add op_key to arguments
|
||||
reconciled_arguments["op_key"] = context.request.op_key
|
||||
|
||||
# Create a new request with reconciled arguments
|
||||
|
||||
updated_request = BackendRequest(
|
||||
method_name=context.request.method_name,
|
||||
args=reconciled_arguments,
|
||||
header=context.request.header,
|
||||
op_key=context.request.op_key,
|
||||
)
|
||||
context.request = updated_request
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while parsing arguments for {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["argument_parsing", context.request.method_name],
|
||||
)
|
||||
raise
|
||||
29
pkgs/clan-app/clan_app/api/middleware/base.py
Normal file
29
pkgs/clan-app/clan_app/api/middleware/base.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiddlewareContext:
|
||||
request: "BackendRequest"
|
||||
bridge: "ApiBridge"
|
||||
exit_stack: ExitStack
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Middleware(ABC):
|
||||
"""Abstract base class for middleware components."""
|
||||
|
||||
@abstractmethod
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
"""Process the request through this middleware."""
|
||||
|
||||
def register_context_manager(
|
||||
self, context: MiddlewareContext, cm: AbstractContextManager[Any]
|
||||
) -> Any:
|
||||
"""Register a context manager with the exit stack."""
|
||||
return context.exit_stack.enter_context(cm)
|
||||
99
pkgs/clan-app/clan_app/api/middleware/logging.py
Normal file
99
pkgs/clan-app/clan_app/api/middleware/logging.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import io
|
||||
import logging
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx
|
||||
from clan_lib.custom_logger import RegisteredHandler, setup_logging
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoggingMiddleware(Middleware):
|
||||
"""Middleware that sets up logging context without executing methods."""
|
||||
|
||||
log_manager: LogManager
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
method = context.request.method_name
|
||||
|
||||
try:
|
||||
# Handle log group configuration
|
||||
log_group: list[str] | None = context.request.header.get("logging", {}).get(
|
||||
"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}"
|
||||
)
|
||||
# Create log file
|
||||
log_file = self.log_manager.create_log_file(
|
||||
method, op_key=context.request.op_key, group_path=log_group
|
||||
).get_file_path()
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling request header of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["header_middleware", context.request.method_name],
|
||||
)
|
||||
return
|
||||
|
||||
# Register logging context manager
|
||||
class LoggingContextManager:
|
||||
def __init__(self, log_file: Any) -> None:
|
||||
self.log_file = log_file
|
||||
self.log_f: Any = None
|
||||
self.handler: RegisteredHandler | None = None
|
||||
self.original_ctx: AsyncContext | None = None
|
||||
|
||||
def __enter__(self) -> "LoggingContextManager":
|
||||
self.log_f = self.log_file.open("ab")
|
||||
self.original_ctx = get_async_ctx()
|
||||
|
||||
# Set up async context for logging
|
||||
ctx = AsyncContext(**self.original_ctx.__dict__)
|
||||
ctx.stderr = self.log_f
|
||||
ctx.stdout = self.log_f
|
||||
set_async_ctx(ctx)
|
||||
|
||||
# Set up logging handler
|
||||
handler_stream = io.TextIOWrapper(
|
||||
self.log_f, # type: ignore[arg-type]
|
||||
encoding="utf-8",
|
||||
write_through=True,
|
||||
line_buffering=True,
|
||||
)
|
||||
self.handler = setup_logging(
|
||||
log.getEffectiveLevel(), log_file=handler_stream
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self.handler:
|
||||
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
||||
self.handler.new_handler.close()
|
||||
if self.log_f:
|
||||
self.log_f.close()
|
||||
if self.original_ctx:
|
||||
set_async_ctx(self.original_ctx)
|
||||
|
||||
# Register the logging context manager
|
||||
self.register_context_manager(context, LoggingContextManager(log_file))
|
||||
41
pkgs/clan-app/clan_app/api/middleware/method_execution.py
Normal file
41
pkgs/clan-app/clan_app/api/middleware/method_execution.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from clan_lib.api import MethodRegistry
|
||||
|
||||
from clan_app.api.api_bridge import BackendResponse
|
||||
|
||||
from .base import Middleware, MiddlewareContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MethodExecutionMiddleware(Middleware):
|
||||
"""Middleware that handles actual method execution."""
|
||||
|
||||
api: MethodRegistry
|
||||
|
||||
def process(self, context: MiddlewareContext) -> None:
|
||||
method = self.api.functions[context.request.method_name]
|
||||
|
||||
try:
|
||||
# Execute the actual method
|
||||
result = method(**context.request.args)
|
||||
|
||||
response = BackendResponse(
|
||||
body=result,
|
||||
header={},
|
||||
_op_key=context.request.op_key,
|
||||
)
|
||||
context.bridge.send_response(response)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling result of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
str(e),
|
||||
["method_execution", context.request.method_name],
|
||||
)
|
||||
@@ -1,13 +1,9 @@
|
||||
import logging
|
||||
|
||||
from clan_cli.profiler import profile
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import clan_lib.machines.actions # noqa: F401
|
||||
from clan_cli.profiler import profile
|
||||
from clan_lib.api import API, load_in_all_api_functions, tasks
|
||||
from clan_lib.custom_logger import setup_logging
|
||||
from clan_lib.dirs import user_data_dir
|
||||
@@ -15,8 +11,15 @@ from clan_lib.log_manager import LogGroupConfig, LogManager
|
||||
from clan_lib.log_manager import api as log_manager_api
|
||||
|
||||
from clan_app.api.file_gtk import open_file
|
||||
from clan_app.api.middleware import (
|
||||
ArgumentParsingMiddleware,
|
||||
LoggingMiddleware,
|
||||
MethodExecutionMiddleware,
|
||||
)
|
||||
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClanAppOptions:
|
||||
@@ -39,9 +42,6 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
||||
content_uri = f"file://{site_index}"
|
||||
|
||||
webview = Webview(debug=app_opts.debug)
|
||||
webview.title = "Clan App"
|
||||
|
||||
# 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(
|
||||
@@ -51,15 +51,23 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
# Init LogManager global in log_manager_api module
|
||||
log_manager_api.LOG_MANAGER_INSTANCE = log_manager
|
||||
|
||||
# Populate the API global with all functions
|
||||
load_in_all_api_functions()
|
||||
API.overwrite_fn(open_file)
|
||||
|
||||
webview = Webview(
|
||||
debug=app_opts.debug, title="Clan App", size=Size(1280, 1024, SizeHint.NONE)
|
||||
)
|
||||
|
||||
# Add middleware to the webview
|
||||
webview.add_middleware(ArgumentParsingMiddleware(api=API))
|
||||
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
||||
webview.add_middleware(MethodExecutionMiddleware(api=API))
|
||||
|
||||
# Init BAKEND_THREADS global in tasks module
|
||||
tasks.BAKEND_THREADS = webview.threads
|
||||
|
||||
# Populate the API global with all functions
|
||||
load_in_all_api_functions()
|
||||
|
||||
API.overwrite_fn(open_file)
|
||||
webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE)
|
||||
webview.size = Size(1280, 1024, SizeHint.NONE)
|
||||
webview.bind_jsonschema_api(API, log_manager=log_manager)
|
||||
webview.navigate(content_uri)
|
||||
webview.run()
|
||||
return 0
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
# ruff: noqa: TRY301
|
||||
import functools
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from clan_lib.api import (
|
||||
ApiError,
|
||||
ErrorDataClass,
|
||||
MethodRegistry,
|
||||
dataclass_to_dict,
|
||||
from_dict,
|
||||
)
|
||||
from clan_lib.api import MethodRegistry
|
||||
from clan_lib.api.tasks import WebThread
|
||||
from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx
|
||||
from clan_lib.custom_logger import setup_logging
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clan_app.api.middleware import Middleware
|
||||
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -37,226 +32,120 @@ class FuncStatus(IntEnum):
|
||||
FAILURE = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Size:
|
||||
def __init__(self, width: int, height: int, hint: SizeHint) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.hint = hint
|
||||
width: int
|
||||
height: int
|
||||
hint: SizeHint
|
||||
|
||||
|
||||
@dataclass
|
||||
class Webview:
|
||||
def __init__(
|
||||
self, debug: bool = False, size: Size | None = None, window: int | None = None
|
||||
) -> None:
|
||||
self._handle = _webview_lib.webview_create(int(debug), window)
|
||||
self._callbacks: dict[str, Callable[..., Any]] = {}
|
||||
self.threads: dict[str, WebThread] = {}
|
||||
title: str
|
||||
debug: bool = False
|
||||
size: Size | None = None
|
||||
window: int | None = None
|
||||
|
||||
if size:
|
||||
self.size = size
|
||||
# initialized later
|
||||
_bridge: "WebviewBridge | None" = None
|
||||
_handle: Any | None = None
|
||||
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||
_middleware: list["Middleware"] = field(default_factory=list)
|
||||
|
||||
def _create_handle(self) -> None:
|
||||
# Initialize the webview handle
|
||||
handle = _webview_lib.webview_create(int(self.debug), self.window)
|
||||
callbacks: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
# Since we can't use object.__setattr__, we'll initialize differently
|
||||
# by storing in __dict__ directly (this works for init=False fields)
|
||||
self._handle = handle
|
||||
self._callbacks = callbacks
|
||||
|
||||
if self.title:
|
||||
self.set_title(self.title)
|
||||
|
||||
if self.size:
|
||||
self.set_size(self.size)
|
||||
|
||||
@property
|
||||
def handle(self) -> Any:
|
||||
"""Get the webview handle, creating it if necessary."""
|
||||
if self._handle is None:
|
||||
self._create_handle()
|
||||
return self._handle
|
||||
|
||||
@property
|
||||
def bridge(self) -> "WebviewBridge":
|
||||
"""Get the bridge, creating it if necessary."""
|
||||
if self._bridge is None:
|
||||
self.create_bridge()
|
||||
assert self._bridge is not None, "Bridge should be created"
|
||||
return self._bridge
|
||||
|
||||
def api_wrapper(
|
||||
self,
|
||||
log_manager: LogManager,
|
||||
api: MethodRegistry,
|
||||
method_name: str,
|
||||
wrap_method: Callable[..., Any],
|
||||
op_key_bytes: bytes,
|
||||
request_data: bytes,
|
||||
arg: int,
|
||||
) -> None:
|
||||
op_key = op_key_bytes.decode()
|
||||
args = json.loads(request_data.decode())
|
||||
log.debug(f"Calling {method_name}({json.dumps(args, indent=4)})")
|
||||
header: dict[str, Any]
|
||||
|
||||
try:
|
||||
# Initialize dataclasses from the payload
|
||||
reconciled_arguments = {}
|
||||
if len(args) == 1:
|
||||
request = args[0]
|
||||
header = request.get("header", {})
|
||||
msg = f"Expected header to be a dict, got {type(header)}"
|
||||
if not isinstance(header, dict):
|
||||
raise TypeError(msg)
|
||||
body = request.get("body", {})
|
||||
msg = f"Expected body to be a dict, got {type(body)}"
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError(msg)
|
||||
|
||||
for k, v in body.items():
|
||||
# Some functions expect to be called with dataclass instances
|
||||
# But the js api returns dictionaries.
|
||||
# Introspect the function and create the expected dataclass from dict dynamically
|
||||
# Depending on the introspected argument_type
|
||||
arg_class = api.get_method_argtype(method_name, k)
|
||||
|
||||
# TODO: rename from_dict into something like construct_checked_value
|
||||
# from_dict really takes Anything and returns an instance of the type/class
|
||||
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||
elif len(args) > 1:
|
||||
msg = (
|
||||
"Expected a single argument, got multiple arguments to api_wrapper"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
reconciled_arguments["op_key"] = op_key
|
||||
except Exception as e:
|
||||
log.exception(f"Error while parsing arguments for {method_name}")
|
||||
result = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
errors=[
|
||||
ApiError(
|
||||
message="An internal error occured",
|
||||
description=str(e),
|
||||
location=["bind_jsonschema_api", method_name],
|
||||
)
|
||||
],
|
||||
)
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(result), indent=4, ensure_ascii=False
|
||||
)
|
||||
self.return_(op_key, FuncStatus.SUCCESS, serialized)
|
||||
return
|
||||
|
||||
def thread_task(stop_event: threading.Event) -> None:
|
||||
ctx: AsyncContext = get_async_ctx()
|
||||
ctx.should_cancel = lambda: stop_event.is_set()
|
||||
|
||||
try:
|
||||
# If the API call has set log_group in metadata,
|
||||
# create the log file under that group.
|
||||
log_group: list[str] = header.get("logging", {}).get("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)
|
||||
log.warning(
|
||||
f"Using log group {log_group} for {method_name} with op_key {op_key}"
|
||||
)
|
||||
|
||||
log_file = log_manager.create_log_file(
|
||||
wrap_method, op_key=op_key, group_path=log_group
|
||||
).get_file_path()
|
||||
except Exception as e:
|
||||
log.exception(f"Error while handling request header of {method_name}")
|
||||
result = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
errors=[
|
||||
ApiError(
|
||||
message="An internal error occured",
|
||||
description=str(e),
|
||||
location=["header_middleware", method_name],
|
||||
)
|
||||
],
|
||||
)
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(result), indent=4, ensure_ascii=False
|
||||
)
|
||||
self.return_(op_key, FuncStatus.SUCCESS, serialized)
|
||||
|
||||
with log_file.open("ab") as log_f:
|
||||
# Redirect all cmd.run logs to this file.
|
||||
ctx.stderr = log_f
|
||||
ctx.stdout = log_f
|
||||
set_async_ctx(ctx)
|
||||
|
||||
# Add a new handler to the root logger that writes to log_f
|
||||
handler_stream = io.TextIOWrapper(
|
||||
log_f, encoding="utf-8", write_through=True, line_buffering=True
|
||||
)
|
||||
handler = setup_logging(
|
||||
log.getEffectiveLevel(), log_file=handler_stream
|
||||
)
|
||||
|
||||
try:
|
||||
# Original logic: call the wrapped API method.
|
||||
result = wrap_method(**reconciled_arguments)
|
||||
wrapped_result = {"body": dataclass_to_dict(result), "header": {}}
|
||||
|
||||
# Serialize the result to JSON.
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(wrapped_result), indent=4, ensure_ascii=False
|
||||
)
|
||||
|
||||
# This log message will now also be written to log_f
|
||||
# through the thread_log_handler.
|
||||
log.debug(f"Result for {method_name}: {serialized}")
|
||||
|
||||
# Return the successful result.
|
||||
self.return_(op_key, FuncStatus.SUCCESS, serialized)
|
||||
except Exception as e:
|
||||
log.exception(f"Error while handling result of {method_name}")
|
||||
result = ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
errors=[
|
||||
ApiError(
|
||||
message="An internal error occured",
|
||||
description=str(e),
|
||||
location=["bind_jsonschema_api", method_name],
|
||||
)
|
||||
],
|
||||
)
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(result), indent=4, ensure_ascii=False
|
||||
)
|
||||
self.return_(op_key, FuncStatus.SUCCESS, serialized)
|
||||
finally:
|
||||
# Crucial cleanup: remove the handler from the root logger.
|
||||
# This stops redirecting logs for this thread to log_f and prevents
|
||||
# the handler from being used after log_f is closed.
|
||||
handler.root_logger.removeHandler(handler.new_handler)
|
||||
# Close the handler. For a StreamHandler using a stream it doesn't
|
||||
# own (log_f is managed by the 'with' statement), this typically
|
||||
# flushes the stream.
|
||||
handler.new_handler.close()
|
||||
del self.threads[op_key]
|
||||
|
||||
stop_event = threading.Event()
|
||||
thread = threading.Thread(
|
||||
target=thread_task, args=(stop_event,), name="WebviewThread"
|
||||
"""Legacy API wrapper - delegates to the bridge."""
|
||||
self.bridge.handle_webview_call(
|
||||
method_name=method_name,
|
||||
op_key_bytes=op_key_bytes,
|
||||
request_data=request_data,
|
||||
arg=arg,
|
||||
)
|
||||
thread.start()
|
||||
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
||||
|
||||
def __enter__(self) -> "Webview":
|
||||
return self
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
return self._size
|
||||
def threads(self) -> dict[str, WebThread]:
|
||||
"""Access threads from the bridge for compatibility."""
|
||||
return self.bridge.threads
|
||||
|
||||
@size.setter
|
||||
def size(self, value: Size) -> None:
|
||||
def add_middleware(self, middleware: "Middleware") -> None:
|
||||
"""Add middleware to the middleware chain."""
|
||||
if self._bridge is not None:
|
||||
msg = "Cannot add middleware after bridge creation."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
self._middleware.append(middleware)
|
||||
|
||||
def create_bridge(self) -> "WebviewBridge":
|
||||
"""Create and initialize the WebviewBridge with current middleware."""
|
||||
from .webview_bridge import WebviewBridge
|
||||
|
||||
bridge = WebviewBridge(webview=self, middleware_chain=tuple(self._middleware))
|
||||
self._bridge = bridge
|
||||
return bridge
|
||||
|
||||
# Legacy methods for compatibility
|
||||
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
|
||||
)
|
||||
self._size = value
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str) -> None:
|
||||
_webview_lib.webview_set_title(self._handle, _encode_c_string(value))
|
||||
self._title = value
|
||||
def set_title(self, value: str) -> None:
|
||||
"""Set the webview title (legacy compatibility)."""
|
||||
_webview_lib.webview_set_title(self.handle, _encode_c_string(value))
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the webview."""
|
||||
for name in list(self._callbacks.keys()):
|
||||
self.unbind(name)
|
||||
_webview_lib.webview_terminate(self._handle)
|
||||
_webview_lib.webview_destroy(self._handle)
|
||||
self._handle = None
|
||||
_webview_lib.webview_terminate(self.handle)
|
||||
_webview_lib.webview_destroy(self.handle)
|
||||
# Can't set _handle to None on frozen dataclass
|
||||
|
||||
def navigate(self, url: str) -> None:
|
||||
_webview_lib.webview_navigate(self._handle, _encode_c_string(url))
|
||||
"""Navigate to a URL."""
|
||||
_webview_lib.webview_navigate(self.handle, _encode_c_string(url))
|
||||
|
||||
def run(self) -> None:
|
||||
_webview_lib.webview_run(self._handle)
|
||||
"""Run the webview."""
|
||||
_webview_lib.webview_run(self.handle)
|
||||
log.info("Shutting down webview...")
|
||||
self.destroy()
|
||||
|
||||
@@ -264,8 +153,6 @@ class Webview:
|
||||
for name, method in api.functions.items():
|
||||
wrapper = functools.partial(
|
||||
self.api_wrapper,
|
||||
log_manager,
|
||||
api,
|
||||
name,
|
||||
method,
|
||||
)
|
||||
@@ -277,7 +164,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:
|
||||
@@ -293,29 +180,23 @@ class Webview:
|
||||
|
||||
c_callback = _webview_lib.binding_callback_t(wrapper)
|
||||
self._callbacks[name] = c_callback
|
||||
_webview_lib.webview_bind(
|
||||
self._handle, _encode_c_string(name), c_callback, None
|
||||
)
|
||||
_webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None)
|
||||
|
||||
def unbind(self, name: str) -> None:
|
||||
if name in self._callbacks:
|
||||
_webview_lib.webview_unbind(self._handle, _encode_c_string(name))
|
||||
_webview_lib.webview_unbind(self.handle, _encode_c_string(name))
|
||||
del self._callbacks[name]
|
||||
|
||||
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:
|
||||
_webview_lib.webview_eval(self._handle, _encode_c_string(source))
|
||||
|
||||
def init(self, source: str) -> None:
|
||||
_webview_lib.webview_init(self._handle, _encode_c_string(source))
|
||||
_webview_lib.webview_eval(self.handle, _encode_c_string(source))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
wv = Webview()
|
||||
wv.title = "Hello, World!"
|
||||
wv = Webview(title="Hello, World!")
|
||||
wv.navigate("https://www.google.com")
|
||||
wv.run()
|
||||
|
||||
102
pkgs/clan-app/clan_app/deps/webview/webview_bridge.py
Normal file
102
pkgs/clan-app/clan_app/deps/webview/webview_bridge.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from clan_lib.api import dataclass_to_dict
|
||||
from clan_lib.api.tasks import WebThread
|
||||
from clan_lib.async_run import set_should_cancel
|
||||
|
||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||
|
||||
from .webview import FuncStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .webview import Webview
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebviewBridge(ApiBridge):
|
||||
"""Webview-specific implementation of the API bridge."""
|
||||
|
||||
webview: "Webview"
|
||||
threads: dict[str, WebThread] = field(default_factory=dict)
|
||||
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the webview client."""
|
||||
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(response), indent=4, ensure_ascii=False
|
||||
)
|
||||
|
||||
log.debug(f"Sending response: {serialized}")
|
||||
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
|
||||
|
||||
def handle_webview_call(
|
||||
self,
|
||||
method_name: str,
|
||||
op_key_bytes: bytes,
|
||||
request_data: bytes,
|
||||
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())
|
||||
|
||||
# Parse the webview-specific request format
|
||||
header = {}
|
||||
args = {}
|
||||
|
||||
if len(raw_args) == 1:
|
||||
request = raw_args[0]
|
||||
header = request.get("header", {})
|
||||
if not isinstance(header, dict):
|
||||
msg = f"Expected header to be a dict, got {type(header)}"
|
||||
raise TypeError(msg) # noqa: TRY301
|
||||
|
||||
body = request.get("body", {})
|
||||
if not isinstance(body, dict):
|
||||
msg = f"Expected body to be a dict, got {type(body)}"
|
||||
raise TypeError(msg) # noqa: TRY301
|
||||
|
||||
args = body
|
||||
elif len(raw_args) > 1:
|
||||
msg = "Expected a single argument, got multiple arguments"
|
||||
raise ValueError(msg) # noqa: TRY301
|
||||
|
||||
# Create API request
|
||||
api_request = BackendRequest(
|
||||
method_name=method_name, args=args, header=header, op_key=op_key
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"Error while handling webview call {method_name} with op_key {op_key}"
|
||||
)
|
||||
log.exception(msg)
|
||||
self.send_error_response(op_key, str(e), ["webview_bridge", method_name])
|
||||
return
|
||||
|
||||
# Process in a separate thread
|
||||
def thread_task(stop_event: threading.Event) -> None:
|
||||
set_should_cancel(lambda: stop_event.is_set())
|
||||
|
||||
try:
|
||||
log.debug(
|
||||
f"Calling {method_name}({json.dumps(api_request.args, indent=4)}) with header {json.dumps(api_request.header, indent=4)} and op_key {op_key}"
|
||||
)
|
||||
self.process_request(api_request)
|
||||
finally:
|
||||
self.threads.pop(op_key, None)
|
||||
|
||||
stop_event = threading.Event()
|
||||
thread = threading.Thread(
|
||||
target=thread_task, args=(stop_event,), name="WebviewThread"
|
||||
)
|
||||
thread.start()
|
||||
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
||||
@@ -35,4 +35,3 @@ warn_redundant_casts = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../ui/.fonts
|
||||
5
pkgs/clan-app/ui-2d/.gitignore
vendored
5
pkgs/clan-app/ui-2d/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
app/api
|
||||
app/.fonts
|
||||
|
||||
.vite
|
||||
storybook-static
|
||||
@@ -1 +0,0 @@
|
||||
../ui/.storybook
|
||||
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
|
||||
],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../ui/api
|
||||
@@ -1 +0,0 @@
|
||||
../ui/eslint.config.mjs
|
||||
@@ -1 +0,0 @@
|
||||
../ui/gtk.webview.js
|
||||
@@ -1 +0,0 @@
|
||||
../ui/icons
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Solid App</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"ignore": [
|
||||
"gtk.webview.js",
|
||||
"stylelint.config.js",
|
||||
"util.ts",
|
||||
"src/components/v2/**",
|
||||
"api/**",
|
||||
"tailwind/**"
|
||||
]
|
||||
}
|
||||
1
pkgs/clan-app/ui-2d/package-lock.json
generated
1
pkgs/clan-app/ui-2d/package-lock.json
generated
@@ -1 +0,0 @@
|
||||
../ui/package-lock.json
|
||||
@@ -1 +0,0 @@
|
||||
../ui/package.json
|
||||
@@ -1 +0,0 @@
|
||||
../ui/postcss.config.js
|
||||
@@ -1 +0,0 @@
|
||||
../ui/prettier.config.js
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import type {
|
||||
ComputePositionConfig,
|
||||
ComputePositionReturn,
|
||||
ReferenceElement,
|
||||
} from "@floating-ui/dom";
|
||||
import { computePosition } from "@floating-ui/dom";
|
||||
|
||||
interface UseFloatingOptions<R extends ReferenceElement, F extends HTMLElement>
|
||||
extends Partial<ComputePositionConfig> {
|
||||
whileElementsMounted?: (
|
||||
reference: R,
|
||||
floating: F,
|
||||
update: () => void,
|
||||
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
void | (() => void);
|
||||
}
|
||||
|
||||
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
}
|
||||
|
||||
interface UseFloatingResult extends UseFloatingState {
|
||||
update(): void;
|
||||
}
|
||||
|
||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
reference: () => R | undefined | null,
|
||||
floating: () => F | undefined | null,
|
||||
options?: UseFloatingOptions<R, F>,
|
||||
): UseFloatingResult {
|
||||
const placement = () => options?.placement ?? "bottom";
|
||||
const strategy = () => options?.strategy ?? "absolute";
|
||||
|
||||
const [data, setData] = createSignal<UseFloatingState>({
|
||||
x: null,
|
||||
y: null,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
middlewareData: {},
|
||||
});
|
||||
|
||||
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
||||
|
||||
createEffect(() => {
|
||||
const currentError = error();
|
||||
if (currentError) {
|
||||
throw currentError.value;
|
||||
}
|
||||
});
|
||||
|
||||
const version = createMemo(() => {
|
||||
reference();
|
||||
floating();
|
||||
return {};
|
||||
});
|
||||
|
||||
function update() {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
const capturedVersion = version();
|
||||
computePosition(currentReference, currentFloating, {
|
||||
middleware: options?.middleware,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
}).then(
|
||||
(currentData) => {
|
||||
// Check if it's still valid
|
||||
if (capturedVersion === version()) {
|
||||
setData(currentData);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
placement();
|
||||
strategy();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
if (options?.whileElementsMounted) {
|
||||
const cleanup = options.whileElementsMounted(
|
||||
currentReference,
|
||||
currentFloating,
|
||||
update,
|
||||
);
|
||||
|
||||
if (cleanup) {
|
||||
onCleanup(cleanup);
|
||||
}
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get x() {
|
||||
return data().x;
|
||||
},
|
||||
get y() {
|
||||
return data().y;
|
||||
},
|
||||
get placement() {
|
||||
return data().placement;
|
||||
},
|
||||
get strategy() {
|
||||
return data().strategy;
|
||||
},
|
||||
get middlewareData() {
|
||||
return data().middlewareData;
|
||||
},
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { JSX } from "solid-js";
|
||||
interface LabelProps {
|
||||
label: JSX.Element;
|
||||
required?: boolean;
|
||||
}
|
||||
export const Label = (props: LabelProps) => (
|
||||
<span
|
||||
class=" block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']": props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
interface FormSectionProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
const FormSection = (props: FormSectionProps) => {
|
||||
return <div class="p-2">{props.children}</div>;
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import {
|
||||
createUniqueId,
|
||||
createSignal,
|
||||
Show,
|
||||
type JSX,
|
||||
For,
|
||||
createMemo,
|
||||
Accessor,
|
||||
} from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { useFloating } from "../base";
|
||||
import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import {
|
||||
InputBase,
|
||||
InputError,
|
||||
InputLabel,
|
||||
InputLabelProps,
|
||||
} from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "./layout";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectInputpProps {
|
||||
value: string[] | string;
|
||||
selectProps?: JSX.InputHTMLAttributes<HTMLSelectElement>;
|
||||
options: Option[];
|
||||
label: JSX.Element;
|
||||
labelProps?: InputLabelProps;
|
||||
helperText?: JSX.Element;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
inlineLabel?: JSX.Element;
|
||||
class?: string;
|
||||
adornment?: {
|
||||
position: "start" | "end";
|
||||
content: JSX.Element;
|
||||
};
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
loading?: boolean;
|
||||
portalRef?: Accessor<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function SelectInput(props: SelectInputpProps) {
|
||||
const _id = createUniqueId();
|
||||
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "bottom-start",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
offset({ mainAxis: 2 }),
|
||||
shift(),
|
||||
flip(),
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create values list
|
||||
const getValues = createMemo(() => {
|
||||
return Array.isArray(props.value)
|
||||
? (props.value as string[])
|
||||
: typeof props.value === "string"
|
||||
? [props.value]
|
||||
: [];
|
||||
});
|
||||
|
||||
// const getSingleValue = createMemo(() => {
|
||||
// const values = getValues();
|
||||
// return values.length > 0 ? values[0] : "";
|
||||
// });
|
||||
|
||||
const handleClickOption = (opt: Option) => {
|
||||
if (!props.multiple) {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
value: opt.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
let currValues = getValues();
|
||||
|
||||
if (currValues.includes(opt.value)) {
|
||||
currValues = currValues.filter((o) => o !== opt.value);
|
||||
} else {
|
||||
currValues.push(opt.value);
|
||||
}
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
options: currValues.map((value) => ({
|
||||
value,
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldLayout
|
||||
error={props.error && <InputError error={props.error} />}
|
||||
label={
|
||||
<InputLabel
|
||||
description={""}
|
||||
required={props.required}
|
||||
{...props.labelProps}
|
||||
>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<InputBase
|
||||
error={!!props.error}
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
class="!justify-start"
|
||||
divRef={setReference}
|
||||
inputElem={
|
||||
<button
|
||||
// TODO: Keyboard acessibililty
|
||||
// Currently the popover only opens with onClick
|
||||
// Options are not selectable with keyboard
|
||||
tabIndex={-1}
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
const popover = document.getElementById(_id);
|
||||
if (popover) {
|
||||
popover.togglePopover(); // Show or hide the popover
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2"
|
||||
formnovalidate
|
||||
// TODO: Use native popover once Webkit supports it within <form>
|
||||
// popovertarget={_id}
|
||||
// popovertargetaction="toggle"
|
||||
>
|
||||
<Show
|
||||
when={props.adornment && props.adornment.position === "start"}
|
||||
>
|
||||
{props.adornment?.content}
|
||||
</Show>
|
||||
{props.inlineLabel}
|
||||
<div class="flex cursor-default flex-row gap-2">
|
||||
<Show
|
||||
when={
|
||||
getValues() &&
|
||||
getValues.length !== 1 &&
|
||||
getValues()[0] !== ""
|
||||
}
|
||||
fallback={props.placeholder}
|
||||
>
|
||||
<For each={getValues()} fallback={"Select"}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
|
||||
{item}
|
||||
<Show when={props.multiple}>
|
||||
<button
|
||||
class=""
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
options: getValues()
|
||||
.filter((o) => o !== item)
|
||||
.map((value) => ({
|
||||
value,
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.adornment && props.adornment.position === "end"}
|
||||
>
|
||||
{props.adornment?.content}
|
||||
</Show>
|
||||
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Portal
|
||||
mount={
|
||||
props.portalRef ? props.portalRef() || document.body : document.body
|
||||
}
|
||||
>
|
||||
<div
|
||||
id={_id}
|
||||
popover
|
||||
ref={setFloating}
|
||||
style={{
|
||||
margin: 0,
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
|
||||
<Show when={!props.loading} fallback={"Loading ...."}>
|
||||
<For each={props.options}>
|
||||
{(opt) => (
|
||||
<>
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!justify-start"
|
||||
onClick={() => handleClickOption(opt)}
|
||||
disabled={opt.disabled}
|
||||
classList={{
|
||||
active:
|
||||
!opt.disabled && getValues().includes(opt.value),
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ul>
|
||||
</div>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { splitProps, type JSX } from "solid-js";
|
||||
import {
|
||||
InputBase,
|
||||
InputError,
|
||||
InputLabel,
|
||||
InputVariant,
|
||||
} from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "./layout";
|
||||
|
||||
interface TextInputProps {
|
||||
// Common
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Passed to input
|
||||
value: string;
|
||||
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
|
||||
placeholder?: string;
|
||||
variant?: InputVariant;
|
||||
// Passed to label
|
||||
label: JSX.Element;
|
||||
help?: string;
|
||||
// Passed to layout
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function TextInput(props: TextInputProps) {
|
||||
const [layoutProps, rest] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel
|
||||
class="col-span-2"
|
||||
required={props.required}
|
||||
error={!!props.error}
|
||||
help={props.help}
|
||||
>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<InputBase
|
||||
variant={props.variant}
|
||||
error={!!props.error}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
class="col-span-10"
|
||||
{...props.inputProps}
|
||||
value={props.value}
|
||||
/>
|
||||
}
|
||||
error={props.error && <InputError error={props.error} />}
|
||||
{...layoutProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./FormSection";
|
||||
export * from "./TextInput";
|
||||
@@ -1,26 +0,0 @@
|
||||
import { JSX, splitProps } from "solid-js";
|
||||
import cx from "classnames";
|
||||
|
||||
interface LayoutProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
field?: JSX.Element;
|
||||
label?: JSX.Element;
|
||||
error?: JSX.Element;
|
||||
}
|
||||
export const FieldLayout = (props: LayoutProps) => {
|
||||
const [intern, divProps] = splitProps(props, [
|
||||
"field",
|
||||
"label",
|
||||
"error",
|
||||
"class",
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class={cx("grid grid-cols-10 items-center", intern.class)}
|
||||
{...divProps}
|
||||
>
|
||||
<div class="col-span-5 flex items-center">{props.label}</div>
|
||||
<div class="col-span-5">{props.field}</div>
|
||||
{props.error && <span class="col-span-full">{props.error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
|
||||
interface FieldsetProps {
|
||||
legend?: string;
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function Fieldset(props: FieldsetProps) {
|
||||
return (
|
||||
<fieldset class="flex flex-col gap-y-2.5">
|
||||
{props.legend && (
|
||||
<div class="px-2">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
tag="p"
|
||||
size="s"
|
||||
color="primary"
|
||||
weight="medium"
|
||||
>
|
||||
{props.legend}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex flex-col gap-y-3 rounded-md border border-secondary-200 bg-secondary-50 p-5">
|
||||
{props.children}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
import {
|
||||
createForm,
|
||||
Field,
|
||||
FieldArray,
|
||||
FieldValues,
|
||||
FormStore,
|
||||
pattern,
|
||||
ResponseData,
|
||||
setValue,
|
||||
getValues,
|
||||
insert,
|
||||
SubmitHandler,
|
||||
reset,
|
||||
remove,
|
||||
move,
|
||||
} from "@modular-forms/solid";
|
||||
import { JSONSchema7, JSONSchema7Type } from "json-schema";
|
||||
import { TextInput } from "../fields/TextInput";
|
||||
import { createEffect, For, JSX, Match, Show, Switch } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import { Label } from "../base/label";
|
||||
import { SelectInput } from "../fields/Select";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
function generateDefaults(schema: JSONSchema7): unknown {
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return ""; // Default value for string
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0; // Default value for number/integer
|
||||
|
||||
case "boolean":
|
||||
return false; // Default value for boolean
|
||||
|
||||
case "array":
|
||||
return []; // Default empty array if no items schema or items is true/false
|
||||
|
||||
case "object": {
|
||||
const obj: Record<string, unknown> = {};
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
if (typeof propSchema === "boolean") {
|
||||
obj[key] = false;
|
||||
} else {
|
||||
// if (schema.required schema.required.includes(key))
|
||||
obj[key] = generateDefaults(propSchema);
|
||||
}
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
default:
|
||||
return null; // Default for unknown types or nulls
|
||||
}
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
schema: JSONSchema7;
|
||||
initialValues?: NonNullable<unknown>;
|
||||
handleSubmit?: SubmitHandler<NonNullable<unknown>>;
|
||||
initialPath?: string[];
|
||||
components?: {
|
||||
before?: JSX.Element;
|
||||
after?: JSX.Element;
|
||||
};
|
||||
readonly?: boolean;
|
||||
formProps?: JSX.InputHTMLAttributes<HTMLFormElement>;
|
||||
errorContext?: string;
|
||||
resetOnSubmit?: boolean;
|
||||
}
|
||||
export const DynForm = (props: FormProps) => {
|
||||
const [formStore, { Field, Form: ModuleForm }] = createForm({
|
||||
initialValues: props.initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
||||
values,
|
||||
event,
|
||||
) => {
|
||||
console.log("Submitting form values", values, props.errorContext);
|
||||
props.handleSubmit?.(values, event);
|
||||
// setValue(formStore, "root", null);
|
||||
if (props.resetOnSubmit) {
|
||||
console.log("Resetting form", values, props.initialValues);
|
||||
reset(formStore);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
console.log("FormStore", formStore);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
|
||||
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
|
||||
{props.components?.before}
|
||||
<SchemaFields
|
||||
schema={props.schema}
|
||||
Field={Field}
|
||||
formStore={formStore}
|
||||
path={props.initialPath || []}
|
||||
readonly={!!props.readonly}
|
||||
parent={props.schema}
|
||||
/>
|
||||
{props.components?.after}
|
||||
</ModuleForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface UnsupportedProps {
|
||||
schema: JSONSchema7;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Unsupported = (props: UnsupportedProps) => (
|
||||
<div>
|
||||
{props.error && <div class="font-bold text-error-700">{props.error}</div>}
|
||||
<span>
|
||||
Invalid or unsupported schema entry of type:{" "}
|
||||
<b>{JSON.stringify(props.schema.type)}</b>
|
||||
</span>
|
||||
<pre>
|
||||
<code>{JSON.stringify(props.schema, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SchemaFieldsProps<T extends FieldValues, R extends ResponseData> {
|
||||
formStore: FormStore<T, R>;
|
||||
Field: typeof Field<T, R, never>;
|
||||
schema: JSONSchema7;
|
||||
path: string[];
|
||||
readonly: boolean;
|
||||
parent: JSONSchema7;
|
||||
}
|
||||
function SchemaFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
return (
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
{/* Simple types */}
|
||||
<Match when={props.schema.type === "boolean"}>bool</Match>
|
||||
|
||||
<Match when={props.schema.type === "integer"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "number"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "string"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
{/* Composed types */}
|
||||
<Match when={props.schema.type === "array"}>
|
||||
<ArrayFields {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "object"}>
|
||||
<ObjectFields {...props} schema={props.schema} />
|
||||
</Match>
|
||||
{/* Empty / Null */}
|
||||
<Match when={props.schema.type === "null"}>
|
||||
Dont know how to rendner InputType null
|
||||
<Unsupported schema={props.schema} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function StringField<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (
|
||||
props.schema.type !== "string" &&
|
||||
props.schema.type !== "number" &&
|
||||
props.schema.type !== "integer"
|
||||
) {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as String input.
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { Field } = props;
|
||||
|
||||
const validate = props.schema.pattern
|
||||
? pattern(
|
||||
new RegExp(props.schema.pattern),
|
||||
`String should follow pattern ${props.schema.pattern}`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const commonProps = {
|
||||
label: props.schema.title || props.path.join("."),
|
||||
required:
|
||||
props.parent.required &&
|
||||
props.parent.required.some(
|
||||
(r) => r === props.path[props.path.length - 1],
|
||||
),
|
||||
};
|
||||
const readonly = !!props.readonly;
|
||||
return (
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match
|
||||
when={props.schema.type === "number" || props.schema.type === "integer"}
|
||||
>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
...fieldProps,
|
||||
inputmode: "numeric",
|
||||
pattern: "[0-9.]*",
|
||||
readonly,
|
||||
}}
|
||||
{...commonProps}
|
||||
value={(field.value as unknown as string) || ""}
|
||||
error={field.error}
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.schema.enum}>
|
||||
{(_enumSchemas) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<OnlyStringItems itemspec={props.schema}>
|
||||
{(options) => (
|
||||
<SelectInput
|
||||
error={field.error}
|
||||
// altLabel={props.schema.title}
|
||||
label={props.path.join(".")}
|
||||
helperText={props.schema.description}
|
||||
value={field.value || []}
|
||||
options={options.map((o) => ({
|
||||
value: o,
|
||||
label: o,
|
||||
}))}
|
||||
selectProps={fieldProps}
|
||||
required={!!props.schema.minItems}
|
||||
/>
|
||||
)}
|
||||
</OnlyStringItems>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.schema.writeOnly && props.schema}>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextInput
|
||||
inputProps={{ ...fieldProps, readonly }}
|
||||
value={field.value as unknown as string}
|
||||
// type="password"
|
||||
error={field.error}
|
||||
{...commonProps}
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
{/* TODO: when is it a normal string input? */}
|
||||
<Match when={props.schema}>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextInput
|
||||
inputProps={{ ...fieldProps, readonly }}
|
||||
value={field.value as unknown as string}
|
||||
error={field.error}
|
||||
{...commonProps}
|
||||
// placeholder="foobar"
|
||||
// inlineLabel={
|
||||
// <div class="label">
|
||||
// <span class=""></span>
|
||||
// </div>
|
||||
// }
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionSchemaProps {
|
||||
itemSpec: JSONSchema7Type;
|
||||
}
|
||||
function OptionSchema(props: OptionSchemaProps) {
|
||||
return (
|
||||
<Switch
|
||||
fallback={<option class="text-error-700">Item spec unhandled</option>}
|
||||
>
|
||||
<Match when={typeof props.itemSpec === "string" && props.itemSpec}>
|
||||
{(o) => <option>{o()}</option>}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueDisplayProps<T extends FieldValues, R extends ResponseData>
|
||||
extends SchemaFieldsProps<T, R> {
|
||||
children: JSX.Element;
|
||||
listFieldName: string;
|
||||
idx: number;
|
||||
of: number;
|
||||
}
|
||||
function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
|
||||
props: ValueDisplayProps<T, R>,
|
||||
) {
|
||||
const removeItem = (e: Event) => {
|
||||
e.preventDefault();
|
||||
remove(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ at: props.idx },
|
||||
);
|
||||
};
|
||||
const moveItemBy = (dir: number) => (e: Event) => {
|
||||
e.preventDefault();
|
||||
move(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ from: props.idx, to: props.idx + dir },
|
||||
);
|
||||
};
|
||||
const topMost = () => props.idx === props.of - 1;
|
||||
const bottomMost = () => props.idx === 0;
|
||||
|
||||
return (
|
||||
<div class="w-full border-b border-secondary-200 px-2 pb-4">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
{props.children}
|
||||
<div class="ml-4 min-w-fit">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="s"
|
||||
type="button"
|
||||
onClick={moveItemBy(1)}
|
||||
disabled={topMost()}
|
||||
startIcon={<Icon icon="ArrowBottom" />}
|
||||
class="h-12"
|
||||
></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="s"
|
||||
onClick={moveItemBy(-1)}
|
||||
disabled={bottomMost()}
|
||||
class="h-12"
|
||||
startIcon={<Icon icon="ArrowTop" />}
|
||||
></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="s"
|
||||
class="h-12"
|
||||
startIcon={<Icon icon="Trash" />}
|
||||
onClick={removeItem}
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const findDuplicates = (arr: unknown[]) => {
|
||||
const seen = new Set();
|
||||
const duplicates: number[] = [];
|
||||
|
||||
arr.forEach((obj, idx) => {
|
||||
const serializedObj = JSON.stringify(obj);
|
||||
|
||||
if (seen.has(serializedObj)) {
|
||||
duplicates.push(idx);
|
||||
} else {
|
||||
seen.add(serializedObj);
|
||||
}
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
};
|
||||
|
||||
interface OnlyStringItems {
|
||||
children: (items: string[]) => JSX.Element;
|
||||
itemspec: JSONSchema7;
|
||||
}
|
||||
const OnlyStringItems = (props: OnlyStringItems) => {
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
Array.isArray(props.itemspec.enum) &&
|
||||
typeof props.itemspec.type === "string" &&
|
||||
props.itemspec
|
||||
}
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={props.itemspec}
|
||||
error="Unsupported array item type"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children(props.itemspec.enum as string[])}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
function ArrayFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (props.schema.type !== "array") {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as array.
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { Field } = props;
|
||||
|
||||
const listFieldName = props.path.join(".");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match
|
||||
when={
|
||||
!Array.isArray(props.schema.items) &&
|
||||
typeof props.schema.items === "object" &&
|
||||
props.schema.items
|
||||
}
|
||||
>
|
||||
{(itemsSchema) => (
|
||||
<>
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match when={itemsSchema().type === "array"}>
|
||||
<Unsupported
|
||||
schema={props.schema}
|
||||
error="Array of Array is not supported yet."
|
||||
/>
|
||||
</Match>
|
||||
<Match
|
||||
when={itemsSchema().type === "string" && itemsSchema().enum}
|
||||
>
|
||||
<Field
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
// @ts-expect-error: type is known due to schema
|
||||
type="string[]"
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
// @ts-expect-error: assumption based on the behavior of selectInput
|
||||
)?.strings?.selection;
|
||||
console.log("vali", { values });
|
||||
if (props.schema.uniqueItems) {
|
||||
const duplicates = findDuplicates(values);
|
||||
if (duplicates.length) {
|
||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.schema.maxItems &&
|
||||
values.length > props.schema.maxItems
|
||||
) {
|
||||
error = `You can only select up to ${props.schema.maxItems} items`;
|
||||
}
|
||||
if (
|
||||
props.schema.minItems &&
|
||||
values.length < props.schema.minItems
|
||||
) {
|
||||
error = `Please select at least ${props.schema.minItems} items.`;
|
||||
}
|
||||
return error;
|
||||
}}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<OnlyStringItems itemspec={itemsSchema()}>
|
||||
{(options) => (
|
||||
<SelectInput
|
||||
multiple
|
||||
error={field.error}
|
||||
// altLabel={props.schema.title}
|
||||
label={listFieldName}
|
||||
helperText={props.schema.description}
|
||||
value={field.value || ""}
|
||||
options={options.map((o) => ({
|
||||
value: o,
|
||||
label: o,
|
||||
}))}
|
||||
selectProps={fieldProps}
|
||||
required={!!props.schema.minItems}
|
||||
/>
|
||||
)}
|
||||
</OnlyStringItems>
|
||||
)}
|
||||
</Field>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
itemsSchema().type === "string" ||
|
||||
itemsSchema().type === "object"
|
||||
}
|
||||
>
|
||||
{/* !Important: Register the parent field to gain access to array items*/}
|
||||
<FieldArray
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
of={props.formStore}
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
);
|
||||
if (props.schema.uniqueItems) {
|
||||
const duplicates = findDuplicates(values);
|
||||
if (duplicates.length) {
|
||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.schema.maxItems &&
|
||||
values.length > props.schema.maxItems
|
||||
) {
|
||||
error = `You can only add up to ${props.schema.maxItems} items`;
|
||||
}
|
||||
if (
|
||||
props.schema.minItems &&
|
||||
values.length < props.schema.minItems
|
||||
) {
|
||||
error = `Please add at least ${props.schema.minItems} items.`;
|
||||
}
|
||||
|
||||
return error;
|
||||
}}
|
||||
>
|
||||
{(fieldArray) => (
|
||||
<>
|
||||
{/* Render existing items */}
|
||||
<For
|
||||
each={fieldArray.items}
|
||||
fallback={
|
||||
// Empty list
|
||||
<span class="text-neutral-500">
|
||||
No {itemsSchema().title || "entries"} yet.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(item, idx) => (
|
||||
<ListValueDisplay
|
||||
{...props}
|
||||
listFieldName={listFieldName}
|
||||
idx={idx()}
|
||||
of={fieldArray.items.length}
|
||||
>
|
||||
<Field
|
||||
// @ts-expect-error: field names are not know ahead of time
|
||||
name={`${listFieldName}.${idx()}`}
|
||||
>
|
||||
{(f, fp) => (
|
||||
<>
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
resetOnSubmit={true}
|
||||
schema={itemsSchema()}
|
||||
initialValues={
|
||||
itemsSchema().type === "object"
|
||||
? f.value
|
||||
: { "": f.value }
|
||||
}
|
||||
readonly={true}
|
||||
></DynForm>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</ListValueDisplay>
|
||||
)}
|
||||
</For>
|
||||
<Show when={fieldArray.error}>
|
||||
<span class="font-bold text-error-700">
|
||||
{fieldArray.error}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* Add new item */}
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("px-2 w-full"),
|
||||
}}
|
||||
schema={{
|
||||
...itemsSchema(),
|
||||
title: itemsSchema().title || "thing",
|
||||
}}
|
||||
initialPath={["root"]}
|
||||
// Reset the input field for list items
|
||||
resetOnSubmit={true}
|
||||
initialValues={{
|
||||
root: generateDefaults(itemsSchema()),
|
||||
}}
|
||||
// Button for adding new items
|
||||
components={{
|
||||
before: (
|
||||
<div class="flex w-full justify-end pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
endIcon={<Icon size={14} icon={"Plus"} />}
|
||||
class="capitalize"
|
||||
>
|
||||
Add {itemsSchema().title}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
// Add the new item to the FieldArray
|
||||
handleSubmit={(values, event) => {
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
const prev: unknown[] = getValues(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
);
|
||||
if (itemsSchema().type === "object") {
|
||||
const newIdx = prev.length;
|
||||
setValue(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
`${listFieldName}.${newIdx}`,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
values.root,
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
insert(props.formStore, listFieldName, {
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
value: values.root,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FieldArray>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ObjectFieldPropertyLabelProps {
|
||||
schema: JSONSchema7;
|
||||
fallback: JSX.Element;
|
||||
}
|
||||
function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) {
|
||||
return (
|
||||
<Switch fallback={props.fallback}>
|
||||
{/* @ts-expect-error: $exportedModuleInfo should exist since we export it */}
|
||||
<Match when={props.schema?.$exportedModuleInfo?.path}>
|
||||
{(path) => path()[path().length - 1]}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (props.schema.type !== "object") {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as Object
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldName = props.path.join(".");
|
||||
const { Field } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={props.schema}
|
||||
error="Dont know how to render objectFields"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match
|
||||
when={!props.schema.additionalProperties && props.schema.properties}
|
||||
>
|
||||
{(properties) => (
|
||||
<For each={Object.entries(properties())}>
|
||||
{([propName, propSchema]) => (
|
||||
<div
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
class={cx(
|
||||
"w-full grid grid-cols-1 gap-4 justify-items-start",
|
||||
`p-${props.path.length * 2}`,
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
label={propName}
|
||||
required={props.schema.required?.some((r) => r === propName)}
|
||||
/>
|
||||
|
||||
{typeof propSchema === "object" && (
|
||||
<SchemaFields
|
||||
{...props}
|
||||
schema={propSchema}
|
||||
path={[...props.path, propName]}
|
||||
/>
|
||||
)}
|
||||
{typeof propSchema === "boolean" && (
|
||||
<span class="text-error-700">
|
||||
Schema: Object of Boolean not supported
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</Match>
|
||||
{/* Objects where people can define their own keys
|
||||
- Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select).
|
||||
- Non-trivial Key-value pairs. Where the value is an object or a list
|
||||
*/}
|
||||
<Match
|
||||
when={
|
||||
typeof props.schema.additionalProperties === "object" &&
|
||||
props.schema.additionalProperties
|
||||
}
|
||||
>
|
||||
{(additionalPropertiesSchema) => (
|
||||
<Switch
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={additionalPropertiesSchema()}
|
||||
error="type of additionalProperties not supported yet"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Non-trivival cases */}
|
||||
<Match
|
||||
when={
|
||||
additionalPropertiesSchema().type === "object" &&
|
||||
additionalPropertiesSchema()
|
||||
}
|
||||
>
|
||||
{(itemSchema) => (
|
||||
<Field
|
||||
// Important!: Register the object field to gain access to the dynamic object properties
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
name={fieldName}
|
||||
>
|
||||
{(objectField, fp) => (
|
||||
<>
|
||||
<For
|
||||
fallback={
|
||||
<>
|
||||
<label class="">
|
||||
No{" "}
|
||||
<ObjectFieldPropertyLabel
|
||||
schema={itemSchema()}
|
||||
fallback={"No entries"}
|
||||
/>{" "}
|
||||
yet.
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
each={Object.entries(objectField.value || {})}
|
||||
>
|
||||
{([key, relatedValue]) => (
|
||||
<Field
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
name={`${fieldName}.${key}`}
|
||||
>
|
||||
{(f, fp) => (
|
||||
<div class="w-full border-l-4 border-gray-300 pl-4">
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
schema={itemSchema()}
|
||||
initialValues={f.value}
|
||||
components={{
|
||||
before: (
|
||||
<div class="flex w-full">
|
||||
<span class="text-xl font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-auto"
|
||||
size="s"
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
const copy = {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
...objectField.value,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete copy[key];
|
||||
setValue(
|
||||
props.formStore,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
copy,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon icon="Trash" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</For>
|
||||
{/* Replace this with a normal input ?*/}
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
resetOnSubmit={true}
|
||||
initialValues={{ "": "" }}
|
||||
schema={{
|
||||
type: "string",
|
||||
title: `Entry title or key`,
|
||||
}}
|
||||
handleSubmit={(values, event) => {
|
||||
setValue(
|
||||
props.formStore,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
{ ...objectField.value, [values[""]]: {} },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
additionalPropertiesSchema().type === "array" &&
|
||||
additionalPropertiesSchema()
|
||||
}
|
||||
>
|
||||
{(itemSchema) => (
|
||||
<Unsupported
|
||||
schema={itemSchema()}
|
||||
error="dynamic arrays are not implemented yet"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
{/* TODO: Trivial cases */}
|
||||
</Switch>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { API } from "@/api/API";
|
||||
import { Schema as Inventory } from "@/api/Inventory";
|
||||
import { toast } from "solid-toast";
|
||||
import {
|
||||
ErrorToastComponent,
|
||||
CancelToastComponent,
|
||||
} from "@/src/components/toast";
|
||||
|
||||
type OperationNames = keyof API;
|
||||
type Services = NonNullable<Inventory["services"]>;
|
||||
type ServiceNames = keyof Services;
|
||||
|
||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||
export type OperationResponse<T extends OperationNames> = API[T]["return"];
|
||||
|
||||
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
|
||||
Services[T]
|
||||
>[string];
|
||||
|
||||
export type SuccessQuery<T extends OperationNames> = Extract<
|
||||
OperationResponse<T>,
|
||||
{ status: "success" }
|
||||
>;
|
||||
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
||||
|
||||
interface SendHeaderType {
|
||||
logging?: { group_path: string[] };
|
||||
}
|
||||
interface BackendSendType<K extends OperationNames> {
|
||||
body: OperationArgs<K>;
|
||||
header?: SendHeaderType;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ReceiveHeaderType {}
|
||||
interface BackendReturnType<K extends OperationNames> {
|
||||
body: OperationResponse<K>;
|
||||
header: ReceiveHeaderType;
|
||||
}
|
||||
|
||||
const _callApi = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
backendOpts?: SendHeaderType,
|
||||
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
||||
// if window[method] does not exist, throw an error
|
||||
if (!(method in window)) {
|
||||
console.error(`Method ${method} not found on window object`);
|
||||
// return a rejected promise
|
||||
return {
|
||||
promise: Promise.resolve({
|
||||
body: {
|
||||
status: "error",
|
||||
errors: [
|
||||
{
|
||||
message: `Method ${method} not found on window object`,
|
||||
code: "method_not_found",
|
||||
},
|
||||
],
|
||||
op_key: "noop",
|
||||
},
|
||||
header: {},
|
||||
}),
|
||||
op_key: "noop",
|
||||
};
|
||||
}
|
||||
|
||||
const message: BackendSendType<OperationNames> = {
|
||||
body: args,
|
||||
header: backendOpts,
|
||||
};
|
||||
|
||||
const promise = (
|
||||
window as unknown as Record<
|
||||
OperationNames,
|
||||
(
|
||||
args: BackendSendType<OperationNames>,
|
||||
) => Promise<BackendReturnType<OperationNames>>
|
||||
>
|
||||
)[method](message) as Promise<BackendReturnType<K>>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const op_key = (promise as any)._webviewMessageId as string;
|
||||
|
||||
return { promise, op_key };
|
||||
};
|
||||
|
||||
const handleCancel = async <K extends OperationNames>(
|
||||
ops_key: string,
|
||||
orig_task: Promise<BackendReturnType<K>>,
|
||||
) => {
|
||||
console.log("Canceling operation: ", ops_key);
|
||||
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
|
||||
promise.catch((error) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Unexpected error: " + (error?.message || String(error))}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
console.error("Unhandled promise rejection in callApi:", error);
|
||||
});
|
||||
const resp = await promise;
|
||||
|
||||
if (resp.body.status === "error") {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Failed to cancel operation: " + ops_key}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(orig_task as any).cancelled = true;
|
||||
}
|
||||
console.log("Cancel response: ", resp);
|
||||
};
|
||||
|
||||
export const callApi = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
backendOpts?: SendHeaderType,
|
||||
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
||||
console.log("Calling API", method, args, backendOpts);
|
||||
|
||||
const { promise, op_key } = _callApi(method, args, backendOpts);
|
||||
promise.catch((error) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Unexpected error: " + (error?.message || String(error))}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
console.error("Unhandled promise rejection in callApi:", error);
|
||||
});
|
||||
|
||||
const toastId = toast.custom(
|
||||
(
|
||||
t, // t is the Toast object, t.id is the id of THIS toast instance
|
||||
) => (
|
||||
<CancelToastComponent
|
||||
t={t}
|
||||
message={"Executing " + method}
|
||||
onCancel={handleCancel.bind(null, op_key, promise)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const new_promise = promise.then((response) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cancelled = (promise as any).cancelled;
|
||||
if (cancelled) {
|
||||
console.log("Not printing toast because operation was cancelled");
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
if (body.status === "error" && !cancelled) {
|
||||
toast.remove(toastId);
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Error: " + body.errors[0].message}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.remove(toastId);
|
||||
}
|
||||
return body;
|
||||
});
|
||||
|
||||
return { promise: new_promise, op_key: op_key };
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
getValues,
|
||||
setValue,
|
||||
SubmitHandler,
|
||||
} from "@modular-forms/solid";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import { Button } from "./components/Button/Button";
|
||||
import { callApi } from "./api";
|
||||
import { API } from "@/api/API";
|
||||
import { createSignal, Match, Switch, For, Show } from "solid-js";
|
||||
import { Typography } from "./components/Typography";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import jsonSchema from "@/api/API.json";
|
||||
|
||||
interface APITesterForm extends FieldValues {
|
||||
endpoint: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const ACTUAL_API_ENDPOINT_NAMES: (keyof API)[] = jsonSchema.required.map(
|
||||
(key) => key as keyof API,
|
||||
);
|
||||
|
||||
export const ApiTester = () => {
|
||||
const [persistedTestData, setPersistedTestData] = makePersisted(
|
||||
createSignal<APITesterForm>(),
|
||||
{
|
||||
name: "_test_data",
|
||||
storage: localStorage,
|
||||
},
|
||||
);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<APITesterForm>({
|
||||
initialValues: persistedTestData() || { endpoint: "", payload: "" },
|
||||
});
|
||||
|
||||
const [endpointSearchTerm, setEndpointSearchTerm] = createSignal(
|
||||
getValues(formStore).endpoint || "",
|
||||
);
|
||||
const [showSuggestions, setShowSuggestions] = createSignal(false);
|
||||
|
||||
const filteredEndpoints = () => {
|
||||
const term = endpointSearchTerm().toLowerCase();
|
||||
if (!term) return ACTUAL_API_ENDPOINT_NAMES;
|
||||
return ACTUAL_API_ENDPOINT_NAMES.filter((ep) =>
|
||||
ep.toLowerCase().includes(term),
|
||||
);
|
||||
};
|
||||
|
||||
const query = useQuery(() => {
|
||||
const currentEndpoint = getValues(formStore).endpoint;
|
||||
const currentPayload = getValues(formStore).payload;
|
||||
const values = getValues(formStore);
|
||||
|
||||
return {
|
||||
queryKey: ["api-tester", currentEndpoint, currentPayload],
|
||||
queryFn: async () => {
|
||||
return await callApi(
|
||||
values.endpoint as keyof API,
|
||||
JSON.parse(values.payload || "{}"),
|
||||
).promise;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
};
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<APITesterForm> = (values) => {
|
||||
console.log(values);
|
||||
setPersistedTestData(values);
|
||||
setEndpointSearchTerm(values.endpoint);
|
||||
query.refetch();
|
||||
|
||||
const v = getValues(formStore);
|
||||
console.log(v);
|
||||
};
|
||||
return (
|
||||
<div class="p-2">
|
||||
<h1>API Tester</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div class="flex flex-col">
|
||||
<Field name="endpoint">
|
||||
{(field, fieldProps) => (
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
label={"endpoint"}
|
||||
value={field.value || ""}
|
||||
inputProps={{
|
||||
...fieldProps,
|
||||
onInput: (e: Event) => {
|
||||
if (fieldProps.onInput) {
|
||||
(fieldProps.onInput as (ev: Event) => void)(e);
|
||||
}
|
||||
setEndpointSearchTerm(
|
||||
(e.currentTarget as HTMLInputElement).value,
|
||||
);
|
||||
setShowSuggestions(true);
|
||||
},
|
||||
onBlur: (e: FocusEvent) => {
|
||||
if (fieldProps.onBlur) {
|
||||
(fieldProps.onBlur as (ev: FocusEvent) => void)(e);
|
||||
}
|
||||
setTimeout(() => setShowSuggestions(false), 150);
|
||||
},
|
||||
onFocus: (e: FocusEvent) => {
|
||||
setEndpointSearchTerm(field.value || "");
|
||||
setShowSuggestions(true);
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={showSuggestions() && filteredEndpoints().length > 0}
|
||||
>
|
||||
<ul class="absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border border-gray-300 bg-white shadow-lg">
|
||||
<For each={filteredEndpoints()}>
|
||||
{(ep) => (
|
||||
<li
|
||||
class="cursor-pointer p-2 hover:bg-gray-100"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(formStore, "endpoint", ep);
|
||||
setEndpointSearchTerm(ep);
|
||||
setShowSuggestions(false);
|
||||
}}
|
||||
>
|
||||
{ep}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="payload">
|
||||
{(field, fieldProps) => (
|
||||
<div class="my-2 flex flex-col">
|
||||
<label class="mb-1 font-medium" for="payload-textarea">
|
||||
payload
|
||||
</label>
|
||||
<textarea
|
||||
id="payload-textarea"
|
||||
class="min-h-[120px] resize-y rounded border p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
value={field.value || ""}
|
||||
{...fieldProps}
|
||||
onInput={(e) => {
|
||||
fieldProps.onInput?.(e);
|
||||
}}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Button class="m-2" disabled={query.isFetching}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<div>
|
||||
<Typography hierarchy="title" size="default">
|
||||
Result
|
||||
</Typography>
|
||||
<Switch>
|
||||
<Match when={query.isFetching}>
|
||||
<span>loading ...</span>
|
||||
</Match>
|
||||
<Match when={query.isFetched}>
|
||||
<pre>
|
||||
<code>{JSON.stringify(query.data, null, 2)}</code>
|
||||
</pre>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Button } from "./Button/Button";
|
||||
import Icon from "./icon";
|
||||
|
||||
export const BackButton = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="s"
|
||||
class="mr-2"
|
||||
onClick={() => navigate(-1)}
|
||||
startIcon={<Icon icon="CaretLeft" />}
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
@import "Button-Light.css";
|
||||
@import "Button-Dark.css";
|
||||
@import "Button-Ghost.css";
|
||||
|
||||
.button {
|
||||
@apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold;
|
||||
letter-spacing: 0.0275rem;
|
||||
}
|
||||
|
||||
/* button SIZES */
|
||||
|
||||
.button--default {
|
||||
padding: theme(padding.2) theme(padding.4);
|
||||
height: theme(height.9);
|
||||
border-radius: theme(borderRadius.DEFAULT);
|
||||
|
||||
&:has(> .button__icon--start):has(> .button__label) {
|
||||
padding-left: theme(padding[2.5]);
|
||||
}
|
||||
|
||||
&:has(> .button__icon--end):has(> .button__label) {
|
||||
padding-right: theme(padding[2.5]);
|
||||
}
|
||||
}
|
||||
|
||||
.button--small {
|
||||
padding: theme(padding[1.5]) theme(padding[3]);
|
||||
height: theme(height.8);
|
||||
border-radius: 3px;
|
||||
|
||||
&:has(> .button__icon--start):has(> .button__label) {
|
||||
padding-left: theme(padding.2);
|
||||
}
|
||||
|
||||
&:has(> .button__label):has(> .button__icon--end) {
|
||||
padding-right: theme(padding.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* button group */
|
||||
|
||||
.button-group .button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.button-group .button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.button-group .button:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* button DARK and states */
|
||||
|
||||
.button--dark {
|
||||
@apply border border-solid border-secondary-950 bg-primary-800 text-white;
|
||||
|
||||
box-shadow: inset 1px 1px theme(backgroundColor.secondary.700);
|
||||
|
||||
&:disabled {
|
||||
@apply disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300;
|
||||
}
|
||||
|
||||
& .button__icon {
|
||||
color: theme(textColor.secondary.200);
|
||||
}
|
||||
}
|
||||
|
||||
.button--dark-hover:hover {
|
||||
@apply hover:bg-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-focus:focus {
|
||||
@apply focus:border-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-active:active {
|
||||
@apply focus:border-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-active:active {
|
||||
@apply active:border-secondary-900;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.button--ghost-hover:hover {
|
||||
@apply hover:bg-secondary-100 hover:text-secondary-900;
|
||||
}
|
||||
|
||||
.button--ghost-focus:focus {
|
||||
@apply focus:bg-secondary-200 focus:text-secondary-900;
|
||||
}
|
||||
|
||||
.button--ghost-active:active {
|
||||
@apply active:bg-secondary-200 active:text-secondary-900;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/* button LIGHT and states */
|
||||
|
||||
.button--light {
|
||||
@apply border border-solid border-secondary-400 bg-secondary-100 text-secondary-950;
|
||||
|
||||
box-shadow: inset 1px 1px theme(backgroundColor.white);
|
||||
|
||||
&:disabled {
|
||||
@apply disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700;
|
||||
}
|
||||
|
||||
& .button__icon {
|
||||
color: theme(textColor.secondary.900);
|
||||
}
|
||||
}
|
||||
|
||||
.button--light-hover:hover {
|
||||
@apply hover:bg-secondary-200;
|
||||
}
|
||||
|
||||
.button--light-focus:focus {
|
||||
@apply focus:bg-secondary-200;
|
||||
|
||||
& .button__label {
|
||||
color: theme(textColor.secondary.900) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.button--light-active:active {
|
||||
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900;
|
||||
|
||||
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);
|
||||
|
||||
& .button__label {
|
||||
color: theme(textColor.secondary.900) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { splitProps, type JSX } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import { Typography } from "../Typography";
|
||||
|
||||
import "./Button-Base.css";
|
||||
|
||||
type Variants = "dark" | "light" | "ghost";
|
||||
type Size = "default" | "s";
|
||||
|
||||
const variantColors: (
|
||||
disabled: boolean | undefined,
|
||||
) => Record<Variants, string> = (disabled) => ({
|
||||
dark: cx(
|
||||
"button--dark",
|
||||
!disabled && "button--dark-hover", // Hover state
|
||||
!disabled && "button--dark-focus", // Focus state
|
||||
!disabled && "button--dark-active", // Active state
|
||||
// Disabled
|
||||
"disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300",
|
||||
),
|
||||
light: cx(
|
||||
"button--light",
|
||||
|
||||
!disabled && "button--light-hover", // Hover state
|
||||
!disabled && "button--light-focus", // Focus state
|
||||
!disabled && "button--light-active", // Active state
|
||||
),
|
||||
ghost: cx(
|
||||
!disabled && "button--ghost-hover", // Hover state
|
||||
!disabled && "button--ghost-focus", // Focus state
|
||||
!disabled && "button--ghost-active", // Active state
|
||||
),
|
||||
});
|
||||
|
||||
const sizePaddings: Record<Size, string> = {
|
||||
default: cx("button--default"),
|
||||
s: cx("button button--small"), //cx("rounded-sm py-[0.375rem] px-3"),
|
||||
};
|
||||
|
||||
const sizeFont: Record<Size, string> = {
|
||||
default: cx("text-[0.8125rem]"),
|
||||
s: cx("text-[0.75rem]"),
|
||||
};
|
||||
|
||||
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variants;
|
||||
size?: Size;
|
||||
children?: JSX.Element;
|
||||
startIcon?: JSX.Element;
|
||||
endIcon?: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const [local, other] = splitProps(props, [
|
||||
"children",
|
||||
"variant",
|
||||
"size",
|
||||
"startIcon",
|
||||
"endIcon",
|
||||
"class",
|
||||
]);
|
||||
|
||||
const buttonInvertion = (variant: Variants) => {
|
||||
return !(!variant || variant === "ghost" || variant === "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cx(
|
||||
local.class,
|
||||
"button", // default button class
|
||||
variantColors(props.disabled)[local.variant || "dark"], // button appereance
|
||||
sizePaddings[local.size || "default"], // button size
|
||||
)}
|
||||
{...other}
|
||||
>
|
||||
{local.startIcon && (
|
||||
<span class="button__icon--start">{local.startIcon}</span>
|
||||
)}
|
||||
{local.children && (
|
||||
<Typography
|
||||
class="button__label"
|
||||
hierarchy="label"
|
||||
size={local.size || "default"}
|
||||
color="inherit"
|
||||
inverted={buttonInvertion(local.variant || "dark")}
|
||||
weight="medium"
|
||||
tag="span"
|
||||
>
|
||||
{local.children}
|
||||
</Typography>
|
||||
)}
|
||||
{local.endIcon && <span class="button__icon--end">{local.endIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { createMemo, JSX, Show, splitProps } from "solid-js";
|
||||
|
||||
export interface FileInputProps {
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
name: string;
|
||||
value?: File[] | File;
|
||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||
onClick: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||
accept?: string;
|
||||
required?: boolean;
|
||||
multiple?: boolean;
|
||||
class?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
placeholder?: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* File input field that users can click or drag files into. Various
|
||||
* decorations can be displayed in or around the field to communicate the entry
|
||||
* requirements.
|
||||
*/
|
||||
export function FileInput(props: FileInputProps) {
|
||||
// Split input element props
|
||||
const [, inputProps] = splitProps(props, [
|
||||
"class",
|
||||
"value",
|
||||
"label",
|
||||
"error",
|
||||
"placeholder",
|
||||
]);
|
||||
|
||||
// Create file list
|
||||
const getFiles = createMemo(() =>
|
||||
props.value
|
||||
? Array.isArray(props.value)
|
||||
? props.value
|
||||
: [props.value]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={cx(" w-full", props.class)}>
|
||||
<div class="">
|
||||
<span
|
||||
class=" block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
||||
props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={props.helperText}>
|
||||
<span class=" m-1">{props.helperText}</span>
|
||||
</Show>
|
||||
<div
|
||||
class={cx(
|
||||
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
|
||||
!getFiles().length && "text-slate-500",
|
||||
props.error
|
||||
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
|
||||
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
|
||||
)}
|
||||
>
|
||||
<Show
|
||||
when={getFiles().length}
|
||||
fallback={
|
||||
props.placeholder || (
|
||||
<>Click to select file{props.multiple && "s"}</>
|
||||
)
|
||||
}
|
||||
>
|
||||
Selected file{props.multiple && "s"}:{" "}
|
||||
{getFiles()
|
||||
.map(({ name }) => name)
|
||||
.join(", ")}
|
||||
</Show>
|
||||
<input
|
||||
{...inputProps}
|
||||
// Disable drag n drop
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
class="absolute size-full cursor-pointer opacity-0"
|
||||
type="file"
|
||||
id={props.name}
|
||||
aria-invalid={!!props.error}
|
||||
aria-errormessage={`${props.name}-error`}
|
||||
/>
|
||||
{props.error && (
|
||||
<span class=" font-bold text-error-700">{props.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user