Compare commits

..

188 Commits

Author SHA1 Message Date
DavHau
84a21d1bab generate_test_vars: fix it 2025-07-09 16:20:37 +07:00
kenji
28d5294292 Merge pull request 'pkgs/clan: Add test for clan flash list' (#4281) from kenji/ke-test-flash into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4281
2025-07-09 09:08:34 +00:00
a-kenji
3a52189ed6 pkgs/clan: Add test for clan flash list
Add a test for `clan flash list`.
This tests the basic functionality, as well as that listing actually
works.
2025-07-09 11:02:24 +02:00
kenji
5c33e02e24 Merge pull request 'pkgs/clan: Add test for clan templates list' (#4282) from kenji/ke-test-templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4282
2025-07-09 08:37:26 +00:00
a-kenji
2aa2145876 pkgs/clan: Add test for clan templates list
Add a test for clan templates list.
This tests the basic functionality, as well as that listing actually
works.
2025-07-09 10:29:29 +02:00
hsjobeki
fa517e1149 Merge pull request 'openapi: improve spec compat with swagger.io' (#4279) from openapi into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4279
2025-07-09 07:52:49 +00:00
Johannes Kirschbauer
3828a0cf49 openapi: improve spec compat with swagger.io 2025-07-09 09:40:06 +02:00
hsjobeki
75501a914b Merge pull request 'API Improvements' (#4276) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4276
2025-07-08 21:17:12 +00:00
Johannes Kirschbauer
c9b8bdd6de api/docs: sort resources into tree order 2025-07-08 23:06:20 +02:00
hsjobeki
5a4a7e9158 Merge pull request 'UI: init cubes scene' (#4277) from ui-scene into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4277
2025-07-08 20:40:42 +00:00
Johannes Kirschbauer
83ad0ae836 UI: fix lint 2025-07-08 22:36:14 +02:00
Johannes Kirschbauer
57163cf135 UI: Cubes improve memory usage 2025-07-08 22:33:52 +02:00
Johannes Kirschbauer
13185d005d UI: dispay selected cube base 2025-07-08 22:14:44 +02:00
Johannes Kirschbauer
18a6b57673 UI: Init CubesScene
UI: init cube base scene
2025-07-08 22:14:42 +02:00
kenji
31f2c5106d Merge pull request 'refactor: decouple vars stores from machine instances' (#4269) from davhau/vars-new into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4269
2025-07-08 18:11:03 +00:00
Johannes Kirschbauer
4dfd151cd2 api: rename 'run_machine_deploy' into 'run_machine_update' 2025-07-08 17:21:18 +02:00
Johannes Kirschbauer
8ddd2b607e api/disk_schema: make getter consistent 2025-07-08 17:20:59 +02:00
hsjobeki
b4544b824a Merge pull request 'api/disk_schema: rename getter consistent' (#4274) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4274
2025-07-08 14:43:29 +00:00
hsjobeki
50122c2215 Merge pull request 'docs: disable footer navigation' (#4275) from docs-1 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4275
2025-07-08 14:42:36 +00:00
Johannes Kirschbauer
c65b35edc1 docs: disable footer navigation
This gives the content more space
Especially for plugins (options, developer tabs)
2025-07-08 16:38:33 +02:00
Johannes Kirschbauer
2eb6ee2264 api/disk_schema: make getter consistent 2025-07-08 16:31:51 +02:00
hsjobeki
ec1363aedf Merge pull request 'UI: remove 2d-ui, its broken now since we deleted the symlinked files in #4266' (#4273) from ui-fix into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4273
2025-07-08 14:28:56 +00:00
Johannes Kirschbauer
90495d4157 UI: remove 2d-ui, its broken now since we deleted the symlinked files in #4266 2025-07-08 16:23:30 +02:00
kenji
b8fa4b4677 Merge pull request 'pkgs/cli: Add regression test for clan show' (#4272) from kenji/ke-test-add-show into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4272
2025-07-08 14:01:02 +00:00
a-kenji
ec8effcd46 pkgs/cli: Add regression test for clan show 2025-07-08 15:55:01 +02:00
hsjobeki
09b8e2f49c Merge pull request 'Templates/default: move clan configuration into its own file' (#4262) from templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4262
2025-07-08 13:46:19 +00:00
Johannes Kirschbauer
7fefc8e3b2 Tests: dont expect a hardware-configuration.nix file 2025-07-08 15:34:16 +02:00
Johannes Kirschbauer
62cadb8fbe Docs: mention clan.nix file, which is present in the default template now 2025-07-08 14:58:24 +02:00
Johannes Kirschbauer
f8748d021b tests: dont expect .clan-flake file, it is not strictly required 2025-07-08 14:58:24 +02:00
kenji
b84df095a2 Merge pull request 'templates(minimal): Add .envrc' (#4268) from kenji/ke-template into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4268
2025-07-08 11:58:36 +00:00
DavHau
dbd48a54a3 refactor: decouple vars stores from machine instances
Stores now get machine context from generator objects instead of storing
it internally. This enables future machine-independent generators and
reduces coupling.

- StoreBase.__init__ only takes flake parameter
- Store methods receive machine as explicit parameter
- Fixed all callers to pass machine context
2025-07-08 18:30:16 +07:00
Luis Hebendanz
3b2f0e2029 Merge pull request 'Add middleware interface to clan-app' (#4265) from Qubasa/clan-core:generalize_webview into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4265
2025-07-08 11:16:36 +00:00
Qubasa
639d227055 clan-app: Fix delete tasks
clan-app: Fix delete tasks
2025-07-08 18:11:59 +07:00
a-kenji
7ac4d257d9 templates(minimal): Add .envrc
Add .envrc also to the minimal template to homogenize our templates.
2025-07-08 13:09:04 +02:00
brianmcgee
e45e809553 Merge pull request 'prep-ui-version2' (#4266) from prep-ui-version2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4266
2025-07-08 10:57:54 +00:00
Qubasa
48c7613556 clan-cli: Add verb 'cancel' to openapi 2025-07-08 17:43:14 +07:00
Brian McGee
fe89d954da fix(ui): display required asterisk in label 2025-07-08 11:41:34 +01:00
Brian McGee
b8604d334b feat(ui): prep V2
Preparation for rebuilding the UI with V2 components.
2025-07-08 11:41:33 +01:00
Qubasa
50cbe3c825 clan-app: Move json.loads to try catch 2025-07-08 17:38:24 +07:00
Qubasa
acab3b8905 clan-app: Ignore ruff errors 2025-07-08 17:32:59 +07:00
Qubasa
eb6166796c clan-app: Generalize architecture for API requests 2025-07-08 17:32:59 +07:00
Qubasa
6d8fd42faa clan-app: Add plug and play middleware interface 2025-07-08 17:32:59 +07:00
Qubasa
494830326d clan-app: Add plug and play middleware interface 2025-07-08 17:32:59 +07:00
Luis Hebendanz
edfad04305 Merge pull request 'fix_ruff_regression' (#4264) from Qubasa/clan-core:fix_ruff_regression into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4264
2025-07-08 10:30:45 +00:00
Qubasa
81d3437ff7 clan-cli: Fix Ruff linting errors
clan-cli: Ruff fixes

ignore noqa lint

fix more ruff issues
2025-07-08 17:25:02 +07:00
Qubasa
d656167cf2 ruff: Ignore TRY301 'raise-within-try' as there are legitimate reasons to do this 2025-07-08 17:23:32 +07:00
Qubasa
49e83954c5 clan-cli: Fix regression in ruff linter, where linter rules got overriden by local pyproject.toml
clan-app: Fix ruff regression where linter rules got overriden by local pyproject.toml
2025-07-08 17:23:32 +07:00
Johannes Kirschbauer
8d1e0353f8 test: don't expect a machines folder 2025-07-08 11:53:20 +02:00
Johannes Kirschbauer
05658589a0 Docs: execute 'clan show' as first step 2025-07-08 10:24:01 +02:00
Johannes Kirschbauer
809a115e58 clan/show: fix cli command 2025-07-08 10:15:35 +02:00
Johannes Kirschbauer
79d8d0707b Templates/default/modules/gnome: Add doc-comment - what the module does, how to use it 2025-07-08 10:06:22 +02:00
Johannes Kirschbauer
b2179c9293 Templates/default: remove predefined machines 2025-07-08 10:05:35 +02:00
Johannes Kirschbauer
e33af96705 Templates/default: move clan configuration into its own file
Doing this with the idea in mind, that flake-parts / default should define the same clan in clan.nix
We can add a CI check to ensure both are the same files
They got desynced in the past and describe completely different clans now
2025-07-08 10:04:17 +02:00
Johannes Kirschbauer
14a221d1d1 Docs: remove 'replaces' sentence; it is not describing any purpose 2025-07-08 10:01:51 +02:00
brianmcgee
9f9ab3de19 Merge pull request 'feat(ui): SidebarPane component' (#4248) from ui/sidebar-pane into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4248
2025-07-08 07:37:47 +00:00
hsjobeki
9739a5ae2b Merge pull request 'templates: rename 'new_clan' to default' (#4244) from templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4244
2025-07-08 07:31:22 +00:00
Mic92
54446d751f Merge pull request 'checks/backup: no longer depend on self' (#4258) from self into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4258
2025-07-07 19:57:30 +00:00
Jörg Thalheim
7bc8e091a5 checks/backup: no longer depend on self 2025-07-07 21:51:51 +02:00
Mic92
3462d458ac Merge pull request 'override-inputs: filter out self' (#4257) from improve-perf into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4257
2025-07-07 19:32:51 +00:00
Jörg Thalheim
bd42d67b0c override-inputs: filter out self 2025-07-07 21:25:33 +02:00
Mic92
d99ca36f9f Merge pull request 'checks/eval-module-clan-vars: optimize to use filtered source' (#4255) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4255
2025-07-07 19:02:25 +00:00
Jörg Thalheim
57f9cd9eee checks/eval-module-clan-vars: optimize to use filtered source
- Replace self.filter with lib.fileset for more precise filtering
- Remove unnecessary clan-core dependency from the test
- Test only needs lib and pkgs, not the full flake context
- Prevents unnecessary rebuilds when unrelated files change
2025-07-07 20:55:04 +02:00
Mic92
a9ec94b0df Merge pull request 'checks/inventory: optimize eval tests to use filtered sources' (#4254) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4254
2025-07-07 18:48:58 +00:00
Jörg Thalheim
c64dbceceb checks/inventory: optimize eval tests to use filtered sources
Replace full flake source (self) with minimal filtered filesets to prevent
unnecessary rebuilds when unrelated files change. All three inventory eval
tests now use the same unified fileset containing only necessary files.

This follows the same optimization pattern applied to other eval tests,
significantly reducing rebuild frequency during development.
2025-07-07 20:41:20 +02:00
Mic92
5d924e0c98 Merge pull request 'docs: no longer depend on self' (#4253) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4253
2025-07-07 18:31:35 +00:00
Jörg Thalheim
6a6688019b docs: no longer depend on self 2025-07-07 20:24:11 +02:00
Mic92
f33172fa73 Merge pull request 'don't rebuild eval tests on each ci run' (#4252) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4252
2025-07-07 18:13:57 +00:00
Jörg Thalheim
00914311a4 don't rebuild eval tests on each ci run 2025-07-07 20:05:45 +02:00
Mic92
ceeb40d9ac Merge pull request 'checks/borgbackup: don't rebuild on every pull request' (#4251) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4251
2025-07-07 17:44:16 +00:00
Jörg Thalheim
afab33056e checks/borgbackup: don't rebuild on every pull request 2025-07-07 19:35:48 +02:00
Mic92
a5183f4b4c Merge pull request 'avoid shebang in update-private-flake-inputs' (#4250) from fix-devflake-tryeval into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4250
2025-07-07 16:56:21 +00:00
Jörg Thalheim
a686d7523b avoid shebang in update-private-flake-inputs 2025-07-07 18:48:11 +02:00
Mic92
56b784992d Merge pull request 'devFlake: don't load if sources have been filtered out' (#4249) from fix-devflake-tryeval into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4249
2025-07-07 16:47:27 +00:00
Jörg Thalheim
5f723dc376 devFlake: don't load if sources have been filtered out 2025-07-07 18:38:01 +02:00
Brian McGee
1609989734 feat(ui): SidebarPane component
* implement Divider component using Kobalte's Separator
* refine read only state of form components to match the Sidebar Pane design
* introduce a SidebarPane component with sections that can toggle between editing and view states.
2025-07-07 17:31:58 +01:00
Mic92
0c07d5cfe0 Merge pull request 'add dev flake pattern' (#4245) from private-flake into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4245
2025-07-07 16:02:29 +00:00
Jörg Thalheim
9c37ef4cbe add dev flake pattern
This allows us to have dev dependencies which are not propagated to the user.
2025-07-07 15:59:09 +00:00
Jörg Thalheim
783b6a8b06 add gitea action to update private flake inputs 2025-07-07 15:59:09 +00:00
Jörg Thalheim
4f13049ee2 put flake input overrides into a helper function 2025-07-07 15:59:09 +00:00
Johannes Kirschbauer
2f4f303048 create/clan: do initial commit 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
d02868b950 templates: add .gitignore files to all templates 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
4f7d82671f Templates: remove 'minimal-flake-parts' 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
0dce3fc7ec templates: rename 'new_clan' to default 2025-07-07 15:50:00 +00:00
brianmcgee
a635f9c6fe Merge pull request 'ui: Modal component' (#4241) from feat/modal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4241
2025-07-07 15:16:50 +00:00
Mic92
a8ed1c30e4 Merge pull request 'make treefmt work with git-worktrees' (#4246) from pytest into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4246
2025-07-07 15:07:53 +00:00
Jörg Thalheim
c0c41d52bd make treefmt work with git-worktrees 2025-07-07 16:55:36 +02:00
hsjobeki
bb236bb543 Merge pull request 'Docs: add missing documentation to api functions' (#4243) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4243
2025-07-07 14:02:08 +00:00
Johannes Kirschbauer
d7cf79faa7 openapi: error on missing api function docstring 2025-07-07 15:48:36 +02:00
Johannes Kirschbauer
dab11cb020 docs/api: add docstrings to {list_mdns_services, set_clan_details} 2025-07-07 15:47:14 +02:00
Johannes Kirschbauer
f2cb6fef41 api: remove unused get_directory 2025-07-07 15:45:51 +02:00
Johannes Kirschbauer
655b87ad04 docs/api: add docstrings to {run_machine_install,run_machine_deploy} 2025-07-07 15:41:02 +02:00
Johannes Kirschbauer
d462ae501e docs/api: add docstrings to {check_machine_ssh_login} 2025-07-07 15:38:09 +02:00
Johannes Kirschbauer
59a8c402ba docs/api: add docstrings to {delete_machine} 2025-07-07 15:36:16 +02:00
Johannes Kirschbauer
3b309ea74b docs/api: add docstrings to {get_flash_options, run_machine_flash} 2025-07-07 15:34:49 +02:00
Johannes Kirschbauer
508cd3c784 docs/api: add docstrings to {get_clan_details} 2025-07-07 15:31:06 +02:00
Johannes Kirschbauer
2bff7403df docs/api: add docstrings to {create_clan} 2025-07-07 15:29:19 +02:00
Johannes Kirschbauer
b5a6e809d0 docs/api: add docstrings to {get_generators, run_generators} 2025-07-07 15:22:44 +02:00
Johannes Kirschbauer
ec28c5c307 api/machines: document {get_machine,get_machine_details} 2025-07-07 15:13:23 +02:00
hsjobeki
10f9e5d11b Merge pull request 'api/generators: remove term 'vars' interact purely with 'generators'' (#4242) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4242
2025-07-07 13:04:00 +00:00
Johannes Kirschbauer
b8ba8b79ca api/check_machine_ssh_reachable: add function docs 2025-07-07 15:02:35 +02:00
Johannes Kirschbauer
fd07d02d2d openapi: warn on missing description 2025-07-07 14:52:49 +02:00
Johannes Kirschbauer
2a3d1efc6f api: expose docstring as function description 2025-07-07 14:51:15 +02:00
Johannes Kirschbauer
947e0a5488 openapi: add strict verb checking 2025-07-07 14:35:56 +02:00
Mic92
57b5520143 Merge pull request 'Add missing f to f-string' (#4234) from jfly/clan-core:oops-f-string into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4234
2025-07-07 12:30:20 +00:00
Mic92
9fd1031f4d Merge pull request 'Fix bug? member_id -> member_ip' (#4235) from jfly/clan-core:possible-fix into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4235
2025-07-07 12:30:08 +00:00
Johannes Kirschbauer
c382e8f1f3 api/tasks: rename 'cancel_task' into 'delete_task' 2025-07-07 14:07:53 +02:00
Johannes Kirschbauer
cf92303f31 api/hw: rename 'describe_machine_hardware' into 'get_machine_hardware_summary' 2025-07-07 14:05:57 +02:00
Johannes Kirschbauer
80d0dc9805 api/hw: rename generate_machine_hardware_info into 'run' 2025-07-07 14:04:39 +02:00
Johannes Kirschbauer
4e2cbb188c api/generators: remove term 'vars' interact purely with 'generators' 2025-07-07 13:59:12 +02:00
Brian McGee
eb6460fb40 feat(ui): update playwright to match version in nixpkgs 2025-07-07 12:51:22 +01:00
hsjobeki
155bd36d2b Merge pull request 'api/tasks: prefix impure actions with run' (#4239) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4239
2025-07-07 11:28:07 +00:00
Johannes Kirschbauer
40ea5bf591 api/machine checks: rename, add checkResult 2025-07-07 13:13:00 +02:00
hsjobeki
0cd9c84de0 Merge pull request 'machine/host: degrade into info and add docs' (#4238) from host-info into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4238
Reviewed-by: Luis Hebendanz <consulting@qube.email>
2025-07-07 11:10:05 +00:00
Johannes Kirschbauer
e1ea44a2cc api/clan: rename 'update_clan_meta' -> 'set_clan_details' 2025-07-07 12:51:32 +02:00
Johannes Kirschbauer
7c4865e8b0 api/keygen: add todo comment 2025-07-07 12:49:37 +02:00
Johannes Kirschbauer
b032cd4a29 api/admin: remove maybe_get_admin_public_keys 2025-07-07 12:43:11 +02:00
DavHau
61edc1e06f Refactor StoreBase to take machine name string instead of Machine object
- Updated StoreBase.__init__ to accept machine: str and flake: Flake
- Modified all StoreBase subclasses (in_repo, vm, fs, sops, password_store) to match new signature
- Added select_machine method to Flake class for machine-specific attribute selection
- Updated Machine.select to use the new Flake.select_machine method
- Fixed all test cases to pass machine name and flake to store constructors
- Maintained backward compatibility by keeping the same external API

This reduces coupling between the store system and the Machine class,
making the architecture more modular and flexible.
2025-07-07 10:24:11 +00:00
Johannes Kirschbauer
c369f3b5d1 api/tasks: prefix impure actions with run 2025-07-07 12:09:43 +02:00
hsjobeki
0cc1f072f7 Merge pull request 'api/clan: rename 'show_clan_meta' -> 'get_clan_details'' (#4236) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4236
2025-07-07 10:00:10 +00:00
Johannes Kirschbauer
a2a011a47f machine/host: degrade into info and add docs 2025-07-07 11:52:46 +02:00
Brian McGee
e1796e19e4 feat(ui): refine Fieldset API 2025-07-07 10:51:43 +01:00
Johannes Kirschbauer
972adc7a7c api: chore rename outdated reference 2025-07-07 10:53:32 +02:00
Johannes Kirschbauer
e1b4f296e3 api: rename 'show_mdns' -> 'list_mdns_services' 2025-07-07 10:49:46 +02:00
Johannes Kirschbauer
1cb2156d87 api: rename to get_flash_options 2025-07-07 10:48:14 +02:00
Johannes Kirschbauer
84703fa293 docs: improve docstring for 'list_block_devices' 2025-07-07 10:46:26 +02:00
Johannes Kirschbauer
0e10122d54 api/clan: rename 'show_clan_meta' -> 'get_clan_details' 2025-07-07 10:41:00 +02:00
brianmcgee
ecd731024c Merge pull request 'feat(ui): alert component' (#4199) from ui/alerts into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4199
2025-07-07 08:11:13 +00:00
Jeremy Fleischman
e0da575201 Fix bug? member_id -> member_ip
(I stumbled across this while reading code, I haven't tested this at
all.)
2025-07-07 00:49:45 -07:00
Jeremy Fleischman
3577c689bd Add missing f to f-string 2025-07-07 00:48:32 -07:00
renovate[bot]
885103bfa4 chore(deps): lock file maintenance 2025-07-07 05:40:16 +00:00
Michael Hoang
afc1ca37bd Merge pull request 'cli: don't log every public key we find' (#4233) from push-lynrrnswopmw into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4233
2025-07-07 05:38:03 +00:00
Michael Hoang
4aa536a1bf cli: don't log every public key we find 2025-07-07 15:23:46 +10:00
Michael Hoang
c61dfbf8dd Merge pull request 'treewide: don't generate SSH keys with builder hostname' (#4232) from push-suwrloyoqvlq into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4232
2025-07-07 04:51:21 +00:00
Michael Hoang
e6785fa1d0 treewide: don't generate SSH keys with builder hostname 2025-07-07 14:39:57 +10:00
Michael Hoang
89ea01fd04 Merge pull request 'docs: misc improvements' (#4231) from push-xlwnnlrownnv into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4231
2025-07-07 04:03:33 +00:00
Michael Hoang
a8a08e21e4 clanServices/sshd: add README 2025-07-07 13:54:26 +10:00
Michael Hoang
700f571598 docs: fix highlighting in code block 2025-07-07 13:54:26 +10:00
Michael Hoang
08c15b3d9b docs: remove colon from headings 2025-07-07 13:54:26 +10:00
lassulus
2848b6d5d6 Merge pull request 'vars password-store: fix secret mangling due to string encoding' (#4227) from pass-fix-bytes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4227
2025-07-07 00:50:58 +00:00
lassulus
ddc1059799 vars password-store: fix secret mangling due to string encoding 2025-07-07 02:35:17 +02:00
renovate[bot]
b690515dd7 Update data-mesher digest to a2166c1 2025-07-07 00:10:13 +00:00
lassulus
e9cef9c7c1 Merge pull request 'rename lingering clan.vars -> clan.core.vars' (#4224) from rip_clan_vars into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4224
2025-07-06 23:33:31 +00:00
lassulus
ca69864a20 rename lingering clan.vars -> clan.core.vars 2025-07-07 00:59:52 +02:00
hsjobeki
5436f284fb Merge pull request 'API: refactor into resource oriented names' (#4223) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4223
2025-07-06 19:11:31 +00:00
Johannes Kirschbauer
00df032635 vars/api: rename 'get_generators_closure' into 'get_machine_generators' 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
a2c016718a api/hardware: consolidate into 'describe_machine_hardware' 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
d1abebf068 api/inventory: remove 'inventory' from api entirely 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
9635fb03b7 api/flash: refactor into 'list_flash_options' 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
f48c596617 vars/api: rename, unregister some unused vars functions 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
0589c71601 Vars: rename public functions into 'create_machine_vars' 2025-07-06 20:57:42 +02:00
Johannes Kirschbauer
a2c2d73e49 Vars: rename 'keygen' to 'create_secrets_user' 2025-07-06 20:57:42 +02:00
hsjobeki
99b22dfcbf Merge pull request 'Templates/cli: move display command into it own category' (#4222) from clan-templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4222
2025-07-06 18:26:45 +00:00
Johannes Kirschbauer
cd04686663 Docs: update index 2025-07-06 20:06:17 +02:00
Johannes Kirschbauer
2b3e847c28 machine: rename standalone 'get_host' to 'get_machine_host' 2025-07-06 19:47:58 +02:00
Johannes Kirschbauer
d0ec4fd8e6 Templates/cli: move display command into it own category 2025-07-06 19:36:57 +02:00
hsjobeki
bb5c523ac8 Merge pull request 'Templates: remove InputPrio and related classes' (#4221) from clan-templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4221
2025-07-06 17:19:31 +00:00
Johannes Kirschbauer
4df4f5220b Templates: remove InputPrio and related classes 2025-07-06 19:08:45 +02:00
renovate[bot]
a082fd2ed9 Lock file maintenance 2025-07-06 15:00:31 +00:00
hsjobeki
3161c10aa8 Merge pull request 'templates_url: add clan template url test' (#4216) from clan-templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4216
2025-07-06 14:54:42 +00:00
Johannes Kirschbauer
7ad8ed1af0 Templates: fix invalid mock flake 2025-07-06 16:43:38 +02:00
Johannes Kirschbauer
94919dc9b8 Fix/ui: update create argument 2025-07-06 15:48:35 +02:00
Johannes Kirschbauer
1502cfa4a7 Templates: migrate clan templates to flake identifiers 2025-07-06 15:37:10 +02:00
Johannes Kirschbauer
cce0207225 Templates: remove outdated check for 'configuration.nix' in machine templates 2025-07-06 15:37:10 +02:00
Johannes Kirschbauer
38f98645ac Templates: replace leftover MachineID, by Machine 2025-07-06 15:37:10 +02:00
Johannes Kirschbauer
74d2ae0619 templates_url: add clan template url test 2025-07-06 15:37:10 +02:00
lassulus
c122201ff2 Merge pull request 'Revert "make host key check an enum instead of an literal type"' (#4220) from revert_host_key_check into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4220
2025-07-06 13:19:00 +00:00
lassulus
e72795904d Revert "make host key check an enum instead of an literal type"
This reverts commit 543c518ed0.
2025-07-06 14:51:19 +02:00
hsjobeki
32ddb4ffa7 Merge pull request 'Templates/list: display templates via exposed nix value' (#4219) from templates-list into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4219
2025-07-06 12:49:58 +00:00
Johannes Kirschbauer
db6220b57b Templates/list: display templates via exposed nix value 2025-07-06 14:37:03 +02:00
lassulus
e929f36f80 Merge pull request 'vars/password-store: replace passBackend option with passPackage' (#4134) from lassulus/passage_compat into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4134
2025-07-06 11:44:27 +00:00
hsjobeki
f71460c4f9 Merge pull request 'clan-cli: fix incorrect field name in deploy warning messages. The warning for missing buildHost/targetHost always showed targetHost in the path, even when buildHost was the missing field.' (#4217) from pr-4215 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4217
2025-07-06 10:54:55 +00:00
lassulus
8302f3ffde vars/password-store: replace passBackend option with passPackage
The `clan.core.vars.settings.passBackend` option has been replaced with
`clan.vars.password-store.passPackage` to provide better type safety and
clearer configuration.

Changes:
- Remove problematic mkRemovedOptionModule that caused circular dependency
- Add proper option definition with assertion-based migration
- Users setting the old option get clear migration instructions
- Normal evaluation continues to work for users not using the old option

Migration: Replace `clan.core.vars.settings.passBackend = "passage"`
with `clan.vars.password-store.passPackage = pkgs.passage`
2025-07-06 12:46:39 +02:00
lassulus
bd82de6001 fix(flake): handle file paths with line numbers in cache existence check
The is_cached method now correctly handles store paths that have line
numbers appended (e.g., /nix/store/file.nix:123:456). Previously, these
paths would fail the existence check because the exact path with line
numbers doesn't exist as a file.

The fix adds a helper method that:
- First checks if the exact path exists
- If not, and the path contains colons, validates that the suffix
  consists only of numbers (line:column format)
- If valid, strips the line numbers and checks the base file path

This ensures that cached references to specific file locations are
properly validated while avoiding false positives with files that
have colons in their names.
2025-07-06 12:44:15 +02:00
adeci
06613de825 clan-cli: fix incorrect field name in deploy warning messages. The warning for missing buildHost/targetHost always showed targetHost in the path, even when buildHost was the missing field. 2025-07-06 12:44:02 +02:00
hsjobeki
76af63ee1c Merge pull request 'lib/get_host: improve abstraction, turn missconfiguration into a warning' (#4201) from cli-fixup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4201
2025-07-06 10:38:03 +00:00
Johannes Kirschbauer
3baa43fd87 cli/update: refactor machine selection logic into 'get_machines_for_update' 2025-07-06 12:27:28 +02:00
Johannes Kirschbauer
a6b8ca06ab machines/list: rename helper to instantiate_inventory_to_machines 2025-07-06 12:24:16 +02:00
Johannes Kirschbauer
f7faf2cd63 machines/list: remove duplicate query_machines_by_tags 2025-07-06 12:23:47 +02:00
Johannes Kirschbauer
bff3908bb1 CLI: update requireExplicitUpdate in help 2025-07-06 12:22:25 +02:00
Johannes Kirschbauer
d0613b4030 cli: return validated list from validate_machine_names 2025-07-06 12:22:00 +02:00
Johannes Kirschbauer
52b711667e lib/get_host: improve abstraction, turn missconfiguration into a warning
Motivation: A warning should encourage consistent usage of inventory.machines setting targetHost inside the machine should be considered a custom override

Changing the warning strings to avoid the term 'nix'/'json' both inventory and nixos machines are nix features
2025-07-06 12:08:00 +02:00
lassulus
13d6db98d1 Merge pull request 'better_select_output' (#4213) from better_select_output into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4213
2025-07-06 00:24:06 +00:00
lassulus
195134dd5e clan_cli: better select debug output 2025-07-06 01:17:55 +02:00
lassulus
0670f0ad32 clan_cli flake: remove apply from select, as it will break stuff in horrible ways
Since apply changes the structure of the retuned value, the cache will
be confused about the structure and in subsequent request will use this
wrong structure.

For example: we would use builtins.attrNames on inputs, the flake will
forever think that inputs is a list of strings and will report errors
whenever we try to fetch subkeys from it
2025-07-06 01:17:55 +02:00
lassulus
daf843eeab clan_cli run: add trace runOption to disable verbose traces in debug mode 2025-07-05 19:48:50 +02:00
lassulus
291b742fd7 Merge pull request 'clan_cli machines update: remove caching of sometimes missing pass config' (#4212) from fix_update into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4212
2025-07-05 17:42:45 +00:00
lassulus
f7d6c23aaa clan_cli machines update: remove caching of sometimes missing pass config
This config value is not set if people don't use pass, it's also at the wrong location
We could cache it with a maybe, but we plan to move it anyway
2025-07-05 18:39:53 +02:00
Brian McGee
1f26135381 feat(ui): alert component 2025-07-04 10:51:18 +01:00
421 changed files with 4903 additions and 16225 deletions

75
.gitea/workflows/create-pr.sh Executable file
View 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"

View File

@@ -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

View 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

View File

@@ -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
{

View File

@@ -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" ];
};

View File

@@ -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
'';
};

View File

@@ -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 = {

View File

@@ -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
'';
};

View File

@@ -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
'';
};

View File

@@ -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
'';
};

View File

@@ -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" ];
};

View File

@@ -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

View File

@@ -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 = {

View 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 = { };
};
};
}
```

View File

@@ -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
'';
};
};

View File

@@ -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";
};
}
```

View File

@@ -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
View File

@@ -0,0 +1 @@
sha256-pFUj3KhQ4FkzZT19t+FHBru8u8Lspax0rS2cv7nXIgM=

165
devFlake/private/flake.lock generated Normal file
View 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
}

View 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
View 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

View File

@@ -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

View File

@@ -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 =
[

View File

@@ -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

View File

@@ -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) ];
};
};
};
}

View File

@@ -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`.

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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" = {

View File

@@ -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.

View File

@@ -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:

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -103,10 +103,15 @@ Dont 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, thats expected — [add machines](./add-machines.md) to populate it.
You should see something like this:
```terminal-session
Name: my-clan
Description: None
```
---

View File

@@ -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;

View File

@@ -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;

View File

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

92
flake.lock generated
View File

@@ -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"

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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
View 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)
)
);
}

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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 = [

View File

@@ -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
'';

View File

@@ -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

View File

@@ -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;
};
}

View File

@@ -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
'';
};
}

View File

@@ -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

View File

@@ -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:

View File

@@ -1,4 +1,4 @@
{ lib, pkgs, ... }:
{ lib, pkgs }:
let
eval =
module:

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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.
'';
};
}

View File

@@ -23,6 +23,9 @@
},
{
"path": "../clan-cli/clan_lib"
},
{
"path": "ui-2d"
}
],
"settings": {

View File

@@ -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:

View 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)

View File

@@ -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__)

View 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",
]

View 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

View 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)

View 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))

View 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],
)

View File

@@ -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

View File

@@ -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()

View 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)

View File

@@ -35,4 +35,3 @@ warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true
no_implicit_optional = true

View File

@@ -1 +0,0 @@
../ui/.fonts

View File

@@ -1,5 +0,0 @@
app/api
app/.fonts
.vite
storybook-static

View File

@@ -1 +0,0 @@
../ui/.storybook

View File

@@ -1,7 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
],
"editor.wordWrap": "on"
}

View File

@@ -1 +0,0 @@
../ui/api

View File

@@ -1 +0,0 @@
../ui/eslint.config.mjs

View File

@@ -1 +0,0 @@
../ui/gtk.webview.js

View File

@@ -1 +0,0 @@
../ui/icons

View File

@@ -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>

View File

@@ -1,10 +0,0 @@
{
"ignore": [
"gtk.webview.js",
"stylelint.config.js",
"util.ts",
"src/components/v2/**",
"api/**",
"tailwind/**"
]
}

View File

@@ -1 +0,0 @@
../ui/package-lock.json

View File

@@ -1 +0,0 @@
../ui/package.json

View File

@@ -1 +0,0 @@
../ui/postcss.config.js

View File

@@ -1 +0,0 @@
../ui/prettier.config.js

View File

@@ -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,
};
}

View File

@@ -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>
);

View File

@@ -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>;
};

View File

@@ -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>
</>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -1,2 +0,0 @@
export * from "./FormSection";
export * from "./TextInput";

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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