Merge pull request 'Feat(modules): display clan.service modules' (#3537) from hsjobeki/clan-core:module-list into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3537
This commit is contained in:
hsjobeki
2025-05-07 15:33:49 +00:00
7 changed files with 107 additions and 84 deletions

View File

@@ -45,7 +45,7 @@ let
inherit inventory directory; inherit inventory directory;
flakeInputs = config.self.inputs; flakeInputs = config.self.inputs;
prefix = config._prefix ++ [ "inventoryClass" ]; prefix = config._prefix ++ [ "inventoryClass" ];
localModuleSet = config.self.clan.modules; localModuleSet = config.modules;
} }
); );

View File

@@ -27,10 +27,8 @@ def test_list_modules(test_flake_with_core: FlakeForTest) -> None:
base_path = test_flake_with_core.path base_path = test_flake_with_core.path
modules_info = list_modules(str(base_path)) modules_info = list_modules(str(base_path))
assert len(modules_info.items()) > 1 assert "localModules" in modules_info
# Random test for those two modules assert "modulesPerSource" in modules_info
assert "borgbackup" in modules_info
assert "syncthing" in modules_info
@pytest.mark.impure @pytest.mark.impure

View File

@@ -1,13 +1,11 @@
import json
import re import re
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, TypedDict from typing import Any, TypedDict
from clan_cli.cmd import run_no_stdout from clan_cli.errors import ClanError
from clan_cli.errors import ClanCmdError, ClanError from clan_cli.flake import Flake
from clan_cli.nix import nix_eval
from . import API from . import API
@@ -143,53 +141,50 @@ def get_roles(module_path: Path) -> None | list[str]:
] ]
class ModuleManifest(TypedDict):
name: str
features: dict[str, bool]
@dataclass @dataclass
class ModuleInfo: class ModuleInfo:
description: str manifest: ModuleManifest
readme: str roles: dict[str, None]
categories: list[str]
roles: list[str] | None
features: list[str] = field(default_factory=list)
constraints: dict[str, Any] = field(default_factory=dict)
def get_modules(base_path: str) -> dict[str, str]: class ModuleLists(TypedDict):
cmd = nix_eval( modulesPerSource: dict[str, dict[str, ModuleInfo]]
[ localModules: dict[str, ModuleInfo]
f"{base_path}#clanInternals.inventory.modules",
"--json",
]
)
try:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
except ClanCmdError as e:
msg = "clanInternals might not have inventory.modules attributes"
raise ClanError(
msg,
location=f"list_modules {base_path}",
description="Evaluation failed on clanInternals.inventory.modules attribute",
) from e
modules: dict[str, str] = json.loads(res)
return modules
@API.register @API.register
def list_modules(base_path: str) -> dict[str, ModuleInfo]: def list_modules(base_path: str) -> ModuleLists:
""" """
Show information about a module Show information about a module
""" """
modules = get_modules(base_path) flake = Flake(base_path)
return { modules = flake.select(
module_name: get_module_info(module_name, Path(module_path)) "clanInternals.inventoryClass.{?modulesPerSource,?localModules}"
for module_name, module_path in modules.items() )
} print("Modules found:", modules)
return modules
@dataclass
class LegacyModuleInfo:
description: str
categories: list[str]
roles: None | list[str]
readme: str
features: list[str]
constraints: dict[str, Any]
def get_module_info( def get_module_info(
module_name: str, module_name: str,
module_path: Path, module_path: Path,
) -> ModuleInfo: ) -> LegacyModuleInfo:
""" """
Retrieves information about a module Retrieves information about a module
""" """
@@ -214,7 +209,7 @@ def get_module_info(
readme, f"{module_path}/README.md" readme, f"{module_path}/README.md"
) )
return ModuleInfo( return LegacyModuleInfo(
description=frontmatter.description, description=frontmatter.description,
categories=frontmatter.categories, categories=frontmatter.categories,
roles=get_roles(module_path), roles=get_roles(module_path),

View File

@@ -11,7 +11,10 @@ export const createModulesQuery = (
) => ) =>
createQuery(() => ({ createQuery(() => ({
queryKey: [uri, "list_modules"], queryKey: [uri, "list_modules"],
placeholderData: [], placeholderData: {
localModules: {},
modulesPerSource: {},
},
enabled: !!uri, enabled: !!uri,
queryFn: async () => { queryFn: async () => {
console.log({ uri }); console.log({ uri });
@@ -23,15 +26,13 @@ export const createModulesQuery = (
if (response.status === "error") { if (response.status === "error") {
toast.error("Failed to fetch data"); toast.error("Failed to fetch data");
} else { } else {
if (!filter) { return response.data;
return Object.entries(response.data);
}
return Object.entries(response.data).filter(([key, value]) =>
filter.features.every((f) => (value.features || []).includes(f)),
);
} }
} }
return []; return {
localModules: {},
modulesPerSource: {},
};
}, },
})); }));

View File

@@ -16,11 +16,11 @@ export const ModuleDetails = () => {
<BackButton /> <BackButton />
<div class="p-2"> <div class="p-2">
<h3 class="text-2xl">{params.id}</h3> <h3 class="text-2xl">{params.id}</h3>
<Switch> {/* <Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}> <Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <AddModule data={d()[1]} id={d()[0]} />} {(d) => <AddModule data={d()[1]} id={d()[0]} />}
</Match> </Match>
</Switch> </Switch> */}
</div> </div>
</div> </div>
); );
@@ -40,7 +40,7 @@ export const AddModule = (props: AddModuleProps) => {
<Switch fallback="loading"> <Switch fallback="loading">
<Match when={tags.data}> <Match when={tags.data}>
{(tags) => ( {(tags) => (
<For each={props.data.roles}> <For each={Object.keys(props.data.roles)}>
{(role) => ( {(role) => (
<> <>
<div class="text-neutral-600">{role}s</div> <div class="text-neutral-600">{role}s</div>

View File

@@ -21,11 +21,11 @@ export const ModuleDetails = () => {
<BackButton /> <BackButton />
<div class="p-2"> <div class="p-2">
<h3 class="text-2xl">{params.id}</h3> <h3 class="text-2xl">{params.id}</h3>
<Switch> {/* <Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}> <Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <Details data={d()[1]} id={d()[0]} />} {(d) => <Details data={d()[1]} id={d()[0]} />}
</Match> </Match>
</Switch> </Switch> */}
</div> </div>
</div> </div>
); );
@@ -85,19 +85,24 @@ const Details = (props: DetailsProps) => {
}; };
return ( return (
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<article class="prose">{props.data.description}</article> {/* TODO: bring this feature back */}
<span class="">Categories</span> {/* <article class="prose">{props.data.description}</article> */}
{/* <span class="">Categories</span> */}
<div> <div>
<For each={props.data.categories}> {/* TODO: bring this feature back */}
{/* <For each={props.data.categories}>
{(c) => <div class=" m-1">{c}</div>} {(c) => <div class=" m-1">{c}</div>}
</For> </For> */}
</div> </div>
<span class="">Roles</span> <span class="">Roles</span>
<div> <div>
<For each={props.data.roles}>{(r) => <div class=" m-1">{r}</div>}</For> <For each={Object.keys(props.data.roles)}>
{(r) => <div class=" m-1">{r}</div>}
</For>
</div> </div>
<div class="p-2"> <div class="p-2">
<SolidMarkdown>{props.data.readme}</SolidMarkdown> {/* TODO: bring this feature back */}
{/* <SolidMarkdown>{props.data.readme}</SolidMarkdown> */}
</div> </div>
<div class="my-2 flex w-full gap-2"> <div class="my-2 flex w-full gap-2">
<Button variant="light" onClick={add} startIcon={<Icon icon="Plus" />}> <Button variant="light" onClick={add} startIcon={<Icon icon="Plus" />}>

View File

@@ -12,7 +12,7 @@ import { useQueryClient } from "@tanstack/solid-query";
import cx from "classnames"; import cx from "classnames";
import Icon from "@/src/components/icon"; import Icon from "@/src/components/icon";
export type ModuleInfo = SuccessData<"list_modules">[string]; export type ModuleInfo = SuccessData<"list_modules">["localModules"][string];
interface CategoryProps { interface CategoryProps {
categories: string[]; categories: string[];
@@ -28,7 +28,7 @@ const Categories = (props: CategoryProps) => {
}; };
interface RolesProps { interface RolesProps {
roles: string[]; roles: Record<string, null>;
} }
const Roles = (props: RolesProps) => { const Roles = (props: RolesProps) => {
return ( return (
@@ -38,7 +38,7 @@ const Roles = (props: RolesProps) => {
Service Service
</Typography> </Typography>
</span> </span>
{props.roles.map((role) => ( {Object.keys(props.roles).map((role) => (
<span class="">{role}</span> <span class="">{role}</span>
))} ))}
</div> </div>
@@ -82,7 +82,7 @@ const ModuleItem = (props: {
<A href={`/modules/details/${name}`}> <A href={`/modules/details/${name}`}>
<div class=""> <div class="">
<div class="flex flex-col"> <div class="flex flex-col">
<Categories categories={info.categories} /> {/* <Categories categories={info.categories} /> */}
<Typography hierarchy="title" size="m" weight="medium"> <Typography hierarchy="title" size="m" weight="medium">
{name} {name}
</Typography> </Typography>
@@ -92,11 +92,12 @@ const ModuleItem = (props: {
<div class="w-full"> <div class="w-full">
<Typography hierarchy="body" size="xs"> <Typography hierarchy="body" size="xs">
{info.description} description
{/* TODO: {info.description} */}
</Typography> </Typography>
</div> </div>
</header> </header>
<Roles roles={info.roles || []} /> <Roles roles={info.roles || {}} />
</div> </div>
); );
}; };
@@ -161,23 +162,46 @@ export const ModuleList = () => {
<Switch fallback="Error"> <Switch fallback="Error">
<Match when={modulesQuery.isFetching}>Loading....</Match> <Match when={modulesQuery.isFetching}>Loading....</Match>
<Match when={modulesQuery.data}> <Match when={modulesQuery.data}>
<div {(modules) => (
class="grid gap-6 p-6" <div
classList={{ class="grid gap-6 p-6"
"grid-cols-1": view() === "list", classList={{
"grid-cols-2": view() === "grid", "grid-cols-1": view() === "list",
}} "grid-cols-2": view() === "grid",
> }}
<For each={modulesQuery.data}> >
{([k, v]) => ( <For each={Object.entries(modules().modulesPerSource)}>
<ModuleItem {([sourceName, v]) => (
info={v} <>
name={k} <div>
class={view() == "grid" ? cx("max-w-md") : ""} <Typography size="default" hierarchy="label">
/> {sourceName}
)} </Typography>
</For> </div>
</div> <For each={Object.entries(v)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</>
)}
</For>
<div>{"localModules"}</div>
<For each={Object.entries(modules().localModules)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</div>
)}
</Match> </Match>
</Switch> </Switch>
</> </>