Clan create: migrate to inventory #1732

Merged
clan-bot merged 8 commits from hsjobeki/clan-core:hsjobeki-main into main 2024-07-11 15:09:25 +00:00
28 changed files with 648 additions and 569 deletions

View File

@ -1,6 +1,6 @@
{
"meta": {
"name": "Minimal inventory"
"name": "clan-core"
},
"machines": {
"minimal-inventory-machine": {

View File

@ -28,7 +28,6 @@ Follow the instructions below to set up your development environment and start t
This will start the application in debug mode and link it to the web server running at `http://localhost:3000`.
# clan app (old)
Provides users with the simple functionality to manage their locally registered clans.

View File

@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
def sanitize_string(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def dataclass_to_dict(obj: Any) -> Any:

View File

@ -1,12 +1,12 @@
# !/usr/bin/env python3
import argparse
import json
import os
from dataclasses import dataclass, fields
from pathlib import Path
from clan_cli.api import API
from clan_cli.arg_actions import AppendOptionAction
from clan_cli.inventory import Inventory, InventoryMeta
from ..cmd import CmdOut, run
from ..errors import ClanError
@ -24,19 +24,12 @@ class CreateClanResponse:
flake_update: CmdOut
@dataclass
class ClanMetaInfo:
name: str
description: str | None
icon: str | None
@dataclass
class CreateOptions:
directory: Path | str
# Metadata for the clan
# Metadata can be shown with `clan show`
meta: ClanMetaInfo | None = None
meta: InventoryMeta | None = None
# URL to the template to use. Defaults to the "minimal" template
template_url: str = minimal_template_url
@ -70,13 +63,7 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
)
out = run(command, cwd=directory)
# Write meta.json file if meta is provided
if options.meta is not None:
meta_file = Path(directory / "clan/meta.json")
meta_file.parent.mkdir(parents=True, exist_ok=True)
with open(meta_file, "w") as f:
json.dump(options.meta.__dict__, f)
## Begin: setup git
command = nix_shell(["nixpkgs#git"], ["git", "init"])
out = run(command, cwd=directory)
cmd_responses["git init"] = out
@ -94,6 +81,14 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
)
out = run(command, cwd=directory)
cmd_responses["git config"] = out
## End: setup git
# Write inventory.json file
inventory = Inventory.load_file(directory)
if options.meta is not None:
inventory.meta = options.meta
# Persist creates a commit message for each change
inventory.persist(directory, "Init inventory")
command = ["nix", "flake", "update"]
out = run(command, cwd=directory)
@ -118,7 +113,7 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--meta",
help=f"""Metadata to set for the clan. Available options are: {", ".join([f.name for f in fields(ClanMetaInfo)]) }""",
help=f"""Metadata to set for the clan. Available options are: {", ".join([f.name for f in fields(InventoryMeta)]) }""",
nargs=2,
metavar=("name", "value"),
action=AppendOptionAction,

View File

@ -5,8 +5,8 @@ from pathlib import Path
from urllib.parse import urlparse
from clan_cli.api import API
from clan_cli.clan.create import ClanMetaInfo
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import InventoryMeta
from ..cmd import run_no_stdout
from ..nix import nix_eval
@ -15,10 +15,10 @@ log = logging.getLogger(__name__)
@API.register
def show_clan_meta(uri: str | Path) -> ClanMetaInfo:
def show_clan_meta(uri: str | Path) -> InventoryMeta:
cmd = nix_eval(
[
f"{uri}#clanInternals.meta",
f"{uri}#clanInternals.inventory.meta",
"--json",
]
)
@ -27,11 +27,11 @@ def show_clan_meta(uri: str | Path) -> ClanMetaInfo:
try:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
except ClanCmdError:
except ClanCmdError as e:
raise ClanError(
"Clan might not have meta attributes",
"Evaluation failed on meta attribute",
location=f"show_clan {uri}",
description="Evaluation failed on clanInternals.meta attribute",
description=str(e.cmd),
)
clan_meta = json.loads(res)
@ -61,7 +61,7 @@ def show_clan_meta(uri: str | Path) -> ClanMetaInfo:
description="Icon path must be a URL or a relative path.",
)
return ClanMetaInfo(
return InventoryMeta(
name=clan_meta.get("name"),
description=clan_meta.get("description", None),
icon=icon_path,
@ -73,8 +73,8 @@ def show_command(args: argparse.Namespace) -> None:
meta = show_clan_meta(flake_path)
print(f"Name: {meta.name}")
print(f"Description: {meta.description or ''}")
print(f"Icon: {meta.icon or ''}")
print(f"Description: {meta.description or '-'}")
print(f"Icon: {meta.icon or '-'}")
def register_parser(parser: argparse.ArgumentParser) -> None:

View File

@ -1,35 +1,20 @@
import json
from dataclasses import dataclass
from pathlib import Path
from clan_cli.api import API
from clan_cli.clan.create import ClanMetaInfo
from clan_cli.errors import ClanError
from clan_cli.inventory import Inventory, InventoryMeta
@dataclass
class UpdateOptions:
directory: str
meta: ClanMetaInfo | None = None
meta: InventoryMeta
@API.register
def update_clan_meta(options: UpdateOptions) -> ClanMetaInfo:
meta_file = Path(options.directory) / Path("clan/meta.json")
if not meta_file.exists():
raise ClanError(
"File not found",
description=f"Could not find {meta_file} to update.",
location="update_clan_meta",
)
def update_clan_meta(options: UpdateOptions) -> InventoryMeta:
inventory = Inventory.load_file(options.directory)
inventory.meta = options.meta
meta_content: dict[str, str] = {}
with open(meta_file) as f:
meta_content = json.load(f)
inventory.persist(options.directory, "Update clan meta")
meta_content = {**meta_content, **options.meta.__dict__}
with open(meta_file) as f:
json.dump(meta_content, f)
return ClanMetaInfo(**meta_content)
return inventory.meta

View File

@ -99,14 +99,23 @@ class Service:
)
@dataclass
class InventoryMeta:
name: str
description: str | None = None
icon: str | None = None
@dataclass
class Inventory:
meta: InventoryMeta
machines: dict[str, Machine]
services: dict[str, dict[str, Service]]
@staticmethod
def from_dict(d: dict[str, Any]) -> "Inventory":
return Inventory(
meta=InventoryMeta(**d.get("meta", {})),
machines={
name: Machine.from_dict(machine)
for name, machine in d.get("machines", {}).items()
@ -126,7 +135,9 @@ class Inventory:
@staticmethod
def load_file(flake_dir: str | Path) -> "Inventory":
inventory = Inventory(machines={}, services={})
inventory = Inventory(
machines={}, services={}, meta=InventoryMeta(name="New Clan")
)
inventory_file = Inventory.get_path(flake_dir)
if inventory_file.exists():
with open(inventory_file) as f:

View File

@ -1,6 +1,8 @@
import os
from collections import defaultdict
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from age_keys import SopsSetup
@ -14,7 +16,7 @@ def def_value() -> defaultdict:
# allows defining nested dictionary in a single line
nested_dict = lambda: defaultdict(def_value)
nested_dict: Callable[[], dict[str, Any]] = lambda: defaultdict(def_value)
@pytest.mark.impure

View File

@ -10,8 +10,10 @@
"license": "MIT",
"dependencies": {
"@modular-forms/solid": "^0.21.0",
"@solid-primitives/storage": "^3.7.1",
"@tanstack/solid-query": "^5.44.0",
"material-icons": "^1.13.12",
"nanoid": "^5.0.7",
"solid-js": "^1.8.11",
"solid-toast": "^0.5.0"
},
@ -1498,6 +1500,26 @@
"solid-js": "^1.6.12"
}
},
"node_modules/@solid-primitives/storage": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-3.7.1.tgz",
"integrity": "sha512-tAmZKQg44RjDjrtWO/5hCOrktQspn/yVV0ySb7yKr7B3CVQlTQtldw3W8UetytJSD9podb9cplvvkq75fgpB1Q==",
"dependencies": {
"@solid-primitives/utils": "^6.2.3"
},
"peerDependencies": {
"@tauri-apps/plugin-store": "*",
"solid-js": "^1.6.12"
},
"peerDependenciesMeta": {
"@tauri-apps/plugin-store": {
"optional": true
},
"solid-start": {
"optional": true
}
}
},
"node_modules/@solid-primitives/styles": {
"version": "0.0.111",
"resolved": "https://registry.npmjs.org/@solid-primitives/styles/-/styles-0.0.111.tgz",
@ -1515,7 +1537,6 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.2.3.tgz",
"integrity": "sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==",
"dev": true,
"peerDependencies": {
"solid-js": "^1.6.12"
}
@ -4177,10 +4198,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz",
"integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
"funding": [
{
"type": "github",
@ -4188,10 +4208,10 @@
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@ -4753,6 +4773,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@ -39,8 +39,10 @@
},
"dependencies": {
"@modular-forms/solid": "^0.21.0",
"@solid-primitives/storage": "^3.7.1",
"@tanstack/solid-query": "^5.44.0",
"material-icons": "^1.13.12",
"nanoid": "^5.0.7",
"solid-js": "^1.8.11",
"solid-toast": "^0.5.0"
}

View File

@ -1,24 +1,35 @@
import { createSignal, type Component } from "solid-js";
import { MachineProvider } from "./Config";
import { Layout } from "./layout/layout";
import { Route, Router } from "./Routes";
import { Toaster } from "solid-toast";
import { effect } from "solid-js/web";
import { makePersisted } from "@solid-primitives/storage";
// Some global state
const [route, setRoute] = createSignal<Route>("machines");
export { route, setRoute };
const [currClanURI, setCurrClanURI] = createSignal<string | null>(null);
export { currClanURI, setCurrClanURI };
const [activeURI, setActiveURI] = createSignal<string | null>(null);
export { activeURI, setActiveURI };
const [clanList, setClanList] = makePersisted(createSignal<string[]>([]), {
name: "clanList",
storage: localStorage,
});
export { clanList, setClanList };
const App: Component = () => {
effect(() => {
if (clanList().length === 0) {
setRoute("welcome");
}
});
return [
<Toaster position="top-right" />,
<MachineProvider>
<Layout>
<Router route={route} />
</Layout>
</MachineProvider>,
<Layout>
<Router route={route} />
</Layout>,
];
};

View File

@ -1,67 +0,0 @@
import {
createSignal,
createContext,
useContext,
JSXElement,
createEffect,
} from "solid-js";
import { OperationResponse, pyApi } from "./api";
import { currClanURI } from "./App";
export const makeMachineContext = () => {
const [machines, setMachines] =
createSignal<OperationResponse<"list_machines">>();
const [loading, setLoading] = createSignal(false);
pyApi.list_machines.receive((machines) => {
setLoading(false);
setMachines(machines);
});
createEffect(() => {
console.log("The state is now", machines());
});
return [
{ loading, machines },
{
getMachines: () => {
const clan_dir = currClanURI();
if (clan_dir) {
setLoading(true);
pyApi.list_machines.dispatch({
debug: true,
flake_url: clan_dir,
});
}
// When the gtk function sends its data the loading state will be set to false
},
},
] as const;
// `as const` forces tuple type inference
};
type MachineContextType = ReturnType<typeof makeMachineContext>;
export const MachineContext = createContext<MachineContextType>([
{
loading: () => false,
// eslint-disable-next-line
machines: () => undefined,
},
{
// eslint-disable-next-line
getMachines: () => {},
},
]);
export const useMachineContext = () => useContext(MachineContext);
export function MachineProvider(props: { children: JSXElement }) {
return (
<MachineContext.Provider value={makeMachineContext()}>
{props.children}
</MachineContext.Provider>
);
}

View File

@ -1,17 +1,19 @@
import { Accessor, For, Match, Switch } from "solid-js";
import { MachineListView } from "./routes/machines/view";
import { colors } from "./routes/colors/view";
import { clan } from "./routes/clan/view";
import { CreateClan } from "./routes/clan/view";
import { HostList } from "./routes/hosts/view";
import { BlockDevicesView } from "./routes/blockdevices/view";
import { Flash } from "./routes/flash/view";
import { Settings } from "./routes/settings";
import { Welcome } from "./routes/welcome";
export type Route = keyof typeof routes;
export const routes = {
clan: {
child: clan,
label: "Clan",
createClan: {
child: CreateClan,
label: "Create Clan",
icon: "groups",
},
machines: {
@ -39,6 +41,16 @@ export const routes = {
label: "Colors",
icon: "color_lens",
},
settings: {
child: Settings,
label: "Settings",
icon: "settings",
},
welcome: {
child: Welcome,
label: "welcome",
icon: "settings",
},
};
interface RouterProps {

View File

@ -1,5 +1,6 @@
import { FromSchema } from "json-schema-to-ts";
import { schema } from "@/api";
import { nanoid } from "nanoid";
export type API = FromSchema<typeof schema>;
@ -41,47 +42,44 @@ const operations = schema.properties;
const operationNames = Object.keys(operations) as OperationNames[];
type ObserverRegistry = {
[K in OperationNames]: ((response: OperationResponse<K>) => void)[];
[K in OperationNames]: Record<
string,
(response: OperationResponse<K>) => void
>;
};
const obs: ObserverRegistry = operationNames.reduce(
const registry: ObserverRegistry = operationNames.reduce(
(acc, opName) => ({
...acc,
[opName]: [],
[opName]: {},
}),
{} as ObserverRegistry
);
interface ReceiveOptions {
/**
* Calls only the registered function that has the same key as used with dispatch
*
*/
fnKey: string;
}
function createFunctions<K extends OperationNames>(
operationName: K
): {
dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void) => void;
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
} {
return {
dispatch: (args: OperationArgs<K>) => {
// console.log(
// `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}`
// );
// Send the data to the gtk app
window.webkit.messageHandlers.gtk.postMessage({
method: operationName,
data: args,
});
},
receive: (
fn: (response: OperationResponse<K>) => void
// options?: ReceiveOptions
) => {
obs[operationName].push(fn);
receive: (fn: (response: OperationResponse<K>) => void, id: string) => {
// @ts-expect-error: This should work although typescript doesn't let us write
registry[operationName][id] = fn;
window.clan[operationName] = (s: string) => {
obs[operationName].forEach((f) => deserialize(f)(s));
const f = (response: OperationResponse<K>) => {
if (response.op_key === id) {
registry[operationName][id](response);
}
};
deserialize(f)(s);
};
},
};
@ -90,16 +88,51 @@ function createFunctions<K extends OperationNames>(
type PyApi = {
[K in OperationNames]: {
dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void) => void;
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
};
};
function download(filename: string, text: string) {
const element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text)
);
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>
) => {
return new Promise<OperationResponse<K>>((resolve, reject) => {
const id = nanoid();
pyApi[method].receive((response) => {
if (response.status === "error") {
reject(response);
}
resolve(response);
}, id);
pyApi[method].dispatch({ ...args, op_key: id });
});
};
const deserialize =
<T>(fn: (response: T) => void) =>
(str: string) => {
try {
fn(JSON.parse(str) as T);
} catch (e) {
console.log("Error parsing JSON: ", e);
console.log({ download: () => download("error.json", str) });
console.error(str);
alert(`Error parsing JSON: ${e}`);
}

View File

@ -1,6 +1,5 @@
import { Match, Show, Switch, createSignal } from "solid-js";
import { ErrorData, SuccessData, pyApi } from "../api";
import { currClanURI } from "../App";
type MachineDetails = SuccessData<"list_machines">["data"][string];
@ -23,51 +22,51 @@ const [deploymentInfo, setDeploymentInfo] = createSignal<DeploymentInfo>({});
const [errors, setErrors] = createSignal<MachineErrors>({});
pyApi.show_machine_hardware_info.receive((r) => {
const { op_key } = r;
if (r.status === "error") {
console.error(r.errors);
if (op_key) {
setHwInfo((d) => ({ ...d, [op_key]: { system: null } }));
}
return;
}
if (op_key) {
setHwInfo((d) => ({ ...d, [op_key]: r.data }));
}
});
// pyApi.show_machine_hardware_info.receive((r) => {
// const { op_key } = r;
// if (r.status === "error") {
// console.error(r.errors);
// if (op_key) {
// setHwInfo((d) => ({ ...d, [op_key]: { system: null } }));
// }
// return;
// }
// if (op_key) {
// setHwInfo((d) => ({ ...d, [op_key]: r.data }));
// }
// });
pyApi.show_machine_deployment_target.receive((r) => {
const { op_key } = r;
if (r.status === "error") {
console.error(r.errors);
if (op_key) {
setDeploymentInfo((d) => ({ ...d, [op_key]: null }));
}
return;
}
if (op_key) {
setDeploymentInfo((d) => ({ ...d, [op_key]: r.data }));
}
});
// pyApi.show_machine_deployment_target.receive((r) => {
// const { op_key } = r;
// if (r.status === "error") {
// console.error(r.errors);
// if (op_key) {
// setDeploymentInfo((d) => ({ ...d, [op_key]: null }));
// }
// return;
// }
// if (op_key) {
// setDeploymentInfo((d) => ({ ...d, [op_key]: r.data }));
// }
// });
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info } = props;
const clan_dir = currClanURI();
if (clan_dir) {
pyApi.show_machine_hardware_info.dispatch({
op_key: name,
clan_dir,
machine_name: name,
});
// const clan_dir = currClanURI();
// if (clan_dir) {
// pyApi.show_machine_hardware_info.dispatch({
// op_key: name,
// clan_dir,
// machine_name: name,
// });
pyApi.show_machine_deployment_target.dispatch({
op_key: name,
clan_dir,
machine_name: name,
});
}
// pyApi.show_machine_deployment_target.dispatch({
// op_key: name,
// clan_dir,
// machine_name: name,
// });
// }
return (
<li>

View File

@ -3,3 +3,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow-x: hidden;
overflow-y: scroll;
}

View File

@ -1,4 +1,4 @@
import { currClanURI } from "../App";
import { activeURI, setRoute } from "../App";
export const Header = () => {
return (
@ -14,12 +14,12 @@ export const Header = () => {
</span>
</div>
<div class="flex-1">
<a class="text-xl">{currClanURI() || "Clan"}</a>
<a class="text-xl">{activeURI()}</a>
</div>
<div class="flex-none">
<span class="tooltip tooltip-bottom" data-tip="Account">
<button class="btn btn-square btn-ghost">
<span class="material-icons">account_circle</span>
<span class="tooltip tooltip-bottom" data-tip="Settings">
<button class="link" onClick={() => setRoute("settings")}>
<span class="material-icons">settings</span>
</button>
</span>
</div>

View File

@ -1,13 +1,17 @@
import { Component, JSXElement } from "solid-js";
import { Component, JSXElement, Show } from "solid-js";
import { Header } from "./header";
import { Sidebar } from "../Sidebar";
import { route, setRoute } from "../App";
import { effect } from "solid-js/web";
interface LayoutProps {
children: JSXElement;
}
export const Layout: Component<LayoutProps> = (props) => {
effect(() => {
console.log(route());
});
return (
<>
<div class="drawer bg-base-100 lg:drawer-open">
@ -17,11 +21,16 @@ export const Layout: Component<LayoutProps> = (props) => {
class="drawer-toggle hidden"
/>
<div class="drawer-content">
<Header />
<Show when={route() !== "welcome"}>
<Header />
</Show>
{props.children}
</div>
<div class="drawer-side z-40">
<div
class="drawer-side z-40"
classList={{ "!hidden": route() === "welcome" }}
>
<label
for="toplevel-drawer"
aria-label="close sidebar"

View File

@ -8,24 +8,24 @@ type DevicesModel = Extract<
>["data"]["blockdevices"];
export const BlockDevicesView: Component = () => {
const [devices, setServices] = createSignal<DevicesModel>();
const [devices, setDevices] = createSignal<DevicesModel>();
pyApi.show_block_devices.receive((r) => {
const { status } = r;
if (status === "error") return console.error(r.errors);
setServices(r.data.blockdevices);
});
// pyApi.show_block_devices.receive((r) => {
// const { status } = r;
// if (status === "error") return console.error(r.errors);
// setServices(r.data.blockdevices);
// });
createEffect(() => {
if (route() === "blockdevices") pyApi.show_block_devices.dispatch({});
});
// createEffect(() => {
// if (route() === "blockdevices") pyApi.show_block_devices.dispatch({});
// });
return (
<div>
<div class="tooltip tooltip-bottom" data-tip="Refresh">
<button
class="btn btn-ghost"
onClick={() => pyApi.show_block_devices.dispatch({})}
// onClick={() => pyApi.show_block_devices.dispatch({})}
>
<span class="material-icons ">refresh</span>
</button>

View File

@ -11,45 +11,91 @@ import {
import {
SubmitHandler,
createForm,
email,
required,
custom,
} from "@modular-forms/solid";
import toast from "solid-toast";
import { setActiveURI, setRoute } from "@/src/App";
interface ClanDetailsProps {
directory: string;
}
interface ClanFormProps {
directory?: string;
meta: ClanMeta;
actions: JSX.Element;
editable?: boolean;
}
type CreateForm = Meta & {
template_url: string;
};
export const ClanForm = (props: ClanFormProps) => {
const { meta, actions, editable = true, directory } = props;
const [formStore, { Form, Field }] = createForm<ClanMeta>({
initialValues: meta,
export const ClanForm = () => {
const [formStore, { Form, Field }] = createForm<CreateForm>({
initialValues: {
template_url: "git+https://git.clan.lol/clan/clan-core#templates.minimal",
},
});
const handleSubmit: SubmitHandler<ClanMeta> = (values, event) => {
pyApi.open_file.dispatch({ file_request: { mode: "save" } });
pyApi.open_file.receive((r) => {
if (r.status === "success") {
if (r.data) {
pyApi.create_clan.dispatch({
options: { directory: r.data, meta: values },
});
}
const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => {
const { template_url, ...meta } = values;
pyApi.open_file.dispatch({
file_request: {
mode: "save",
},
return;
}
op_key: "create_clan",
});
console.log("submit", values);
// await new Promise<void>((done) => {
// pyApi.open_file.receive((r) => {
// if (r.op_key !== "create_clan") {
// done();
// return;
// }
// if (r.status !== "success") {
// toast.error("Cannot select clan directory");
// done();
// return;
// }
// const target_dir = r?.data;
// if (!target_dir) {
// toast.error("Cannot select clan directory");
// done();
// return;
// }
// console.log({ formStore });
// toast.promise(
// new Promise<void>((resolve, reject) => {
// pyApi.create_clan.receive((r) => {
// done();
// if (r.status === "error") {
// reject();
// console.error(r.errors);
// return;
// }
// resolve();
// // Navigate to the new clan
// setCurrClanURI(target_dir);
// setRoute("machines");
// });
// pyApi.create_clan.dispatch({
// options: { directory: target_dir, meta, template_url },
// op_key: "create_clan",
// });
// }),
// {
// loading: "Creating clan...",
// success: "Clan Successfully Created",
// error: "Failed to create clan",
// }
// );
// });
// });
};
return (
<div class="card card-compact w-96 bg-base-100 shadow-xl">
<Form onSubmit={handleSubmit}>
<div class="card card-normal">
<Form onSubmit={handleSubmit} shouldActive>
<Field name="icon">
{(field, props) => (
<>
@ -71,13 +117,29 @@ export const ClanForm = (props: ClanFormProps) => {
)}
</Show>
</figure>
<label class="form-control w-full max-w-xs">
</>
)}
</Field>
<div class="card-body">
<Field
name="name"
validate={[required("Please enter a unique name for the clan.")]}
>
{(field, props) => (
<label class="form-control w-full">
<div class="label">
<span class="label-text">Select icon</span>
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
Name
</span>
</div>
<input
type="file"
class="file-input file-input-bordered w-full max-w-xs"
{...props}
required
placeholder="Clan Name"
class="input input-bordered"
classList={{ "input-error": !!field.error }}
value={field.value}
/>
<div class="label">
{field.error && (
@ -85,154 +147,76 @@ export const ClanForm = (props: ClanFormProps) => {
)}
</div>
</label>
</>
)}
</Field>
<div class="card-body">
<div class="card-body">
<Field
name="name"
validate={[required("Please enter a unique name for the clan.")]}
>
{(field, props) => (
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
Name
</span>
</div>
)}
</Field>
<Field name="description">
{(field, props) => (
<label class="form-control w-full">
<div class="label">
<span class="label-text">Description</span>
</div>
<input
{...props}
type="email"
required
placeholder="your.mail@example.com"
class="input w-full max-w-xs"
classList={{ "input-error": !!field.error }}
value={field.value}
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
<Field name="description">
{(field, props) => (
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">Description</span>
</div>
<input
{...props}
required
type="text"
placeholder="Some words about your clan"
class="input w-full max-w-xs"
classList={{ "input-error": !!field.error }}
value={field.value || ""}
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
{actions}
</div>
<input
{...props}
required
type="text"
placeholder="Some words about your clan"
class="input input-bordered"
classList={{ "input-error": !!field.error }}
value={field.value || ""}
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
<Field name="template_url" validate={[required("This is required")]}>
{(field, props) => (
<div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" />
<div class="collapse-title link font-medium ">Advanced</div>
<div class="collapse-content">
<label class="form-control w-full">
<div class="label ">
<span class="label-text after:ml-0.5 after:text-primary after:content-['*']">
Template to use
</span>
</div>
<input
{...props}
required
type="text"
placeholder="Template to use"
class="input input-bordered"
classList={{ "input-error": !!field.error }}
value={field.value}
/>
</label>
</div>
</div>
)}
</Field>
{
<div class="card-actions justify-end">
<button
class="btn btn-primary"
type="submit"
disabled={formStore.submitting}
>
Create
</button>
</div>
}
</div>
</Form>
</div>
);
};
// export const EditMetaFields = (props: MetaFieldsProps) => {
// const { meta, editable, actions, directory } = props;
// const [editing, setEditing] = createSignal<
// keyof MetaFieldsProps["meta"] | null
// >(null);
// return (
// );
// };
type ClanMeta = Extract<
type Meta = Extract<
OperationResponse<"show_clan_meta">,
{ status: "success" }
>["data"];
export const ClanDetails = (props: ClanDetailsProps) => {
const { directory } = props;
const [loading, setLoading] = createSignal(false);
const [errors, setErrors] = createSignal<
| Extract<
OperationResponse<"show_clan_meta">,
{ status: "error" }
>["errors"]
| null
>(null);
const [data, setData] = createSignal<ClanMeta>();
const loadMeta = () => {
pyApi.show_clan_meta.dispatch({ uri: directory });
setLoading(true);
};
createEffect(() => {
loadMeta();
pyApi.show_clan_meta.receive((response) => {
setLoading(false);
if (response.status === "error") {
setErrors(response.errors);
return console.error(response.errors);
}
setData(response.data);
});
});
return (
<Switch fallback={"loading"}>
<Match when={loading()}>
<div>Loading</div>
</Match>
<Match when={data()}>
{(data) => {
const meta = data();
return (
<ClanForm
directory={directory}
meta={meta}
actions={
<div class="card-actions justify-between">
<button class="btn btn-link" onClick={() => loadMeta()}>
Refresh
</button>
<button class="btn btn-primary">Open</button>
</div>
}
/>
);
}}
</Match>
<Match when={errors()}>
<button class="btn btn-secondary" onClick={() => loadMeta()}>
Retry
</button>
<For each={errors()}>
{(item) => (
<div class="flex flex-col gap-3">
<span class="bg-red-400 text-white">{item.message}</span>
<span class="bg-red-400 text-white">{item.description}</span>
<span class="bg-red-400 text-white">{item.location}</span>
</div>
)}
</For>
</Match>
</Switch>
);
};

View File

@ -1,82 +1,9 @@
import { pyApi } from "@/src/api";
import { Match, Switch, createEffect, createSignal } from "solid-js";
import toast from "solid-toast";
import { ClanDetails, ClanForm } from "./clanDetails";
import { ClanForm } from "./clanDetails";
export const clan = () => {
const [mode, setMode] = createSignal<"init" | "open" | "create">("init");
const [clanDir, setClanDir] = createSignal<string | null>(null);
createEffect(() => {
console.log(mode());
});
export const CreateClan = () => {
return (
<div>
<Switch fallback={"invalid"}>
<Match when={mode() === "init"}>
<div class="flex gap-2">
<button class="btn btn-square" onclick={() => setMode("create")}>
<span class="material-icons">add</span>
</button>
<button
class="btn btn-square"
onclick={() => {
pyApi.open_file.dispatch({
file_request: {
mode: "select_folder",
title: "Open Clan",
},
});
pyApi.open_file.receive((r) => {
// There are two error cases to handle
if (r.status !== "success") {
console.error(r.errors);
toast.error("Error opening clan");
return;
}
// User didn't select anything
if (!r.data) {
setMode("init");
return;
}
setClanDir(r.data);
setMode("open");
});
}}
>
<span class="material-icons">folder_open</span>
</button>
</div>
</Match>
<Match when={mode() === "open"}>
<ClanDetails directory={clanDir() || ""} />
</Match>
<Match when={mode() === "create"}>
<ClanForm
actions={
<div class="card-actions justify-end">
<button
class="btn btn-primary"
// onClick={() => {
// pyApi.open_file.dispatch({
// file_request: { mode: "save" },
// });
// }}
>
Save
</button>
</div>
}
meta={{
name: "New Clan",
description: "nice description",
icon: "select icon",
}}
editable
/>
</Match>
</Switch>
<ClanForm />
</div>
);
};

View File

@ -24,16 +24,17 @@ type BlockDevices = Extract<
OperationResponse<"show_block_devices">,
{ status: "success" }
>["data"]["blockdevices"];
export const Flash = () => {
const [formStore, { Form, Field }] = createForm<FlashFormValues>({});
const [devices, setDevices] = createSignal<BlockDevices>([]);
pyApi.show_block_devices.receive((r) => {
console.log("block devices", r);
if (r.status === "success") {
setDevices(r.data.blockdevices);
}
});
// pyApi.show_block_devices.receive((r) => {
// console.log("block devices", r);
// if (r.status === "success") {
// setDevices(r.data.blockdevices);
// }
// });
const handleSubmit: SubmitHandler<FlashFormValues> = (values, event) => {
// pyApi.open_file.dispatch({ file_request: { mode: "save" } });
@ -50,11 +51,11 @@ export const Flash = () => {
console.log("submit", values);
};
effect(() => {
if (route() === "flash") {
pyApi.show_block_devices.dispatch({});
}
});
// effect(() => {
// if (route() === "flash") {
// pyApi.show_block_devices.dispatch({});
// }
// });
return (
<div class="">
<Form onSubmit={handleSubmit}>

View File

@ -16,15 +16,15 @@ type ServiceModel = Extract<
export const HostList: Component = () => {
const [services, setServices] = createSignal<ServiceModel>();
pyApi.show_mdns.receive((r) => {
const { status } = r;
if (status === "error") return console.error(r.errors);
setServices(r.data.services);
});
// pyApi.show_mdns.receive((r) => {
// const { status } = r;
// if (status === "error") return console.error(r.errors);
// setServices(r.data.services);
// });
createEffect(() => {
if (route() === "hosts") pyApi.show_mdns.dispatch({});
});
// createEffect(() => {
// if (route() === "hosts") pyApi.show_mdns.dispatch({});
// });
return (
<div>

View File

@ -7,117 +7,86 @@ import {
createSignal,
type Component,
} from "solid-js";
import { useMachineContext } from "../../Config";
import { route, setCurrClanURI } from "@/src/App";
import { OperationResponse, pyApi } from "@/src/api";
import { activeURI, route, setActiveURI } from "@/src/App";
import { OperationResponse, callApi, pyApi } from "@/src/api";
import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem";
type FilesModel = Extract<
OperationResponse<"get_directory">,
{ status: "success" }
>["data"]["files"];
// type FilesModel = Extract<
// OperationResponse<"get_directory">,
// { status: "success" }
// >["data"]["files"];
type ServiceModel = Extract<
OperationResponse<"show_mdns">,
{ status: "success" }
>["data"]["services"];
// type ServiceModel = Extract<
// OperationResponse<"show_mdns">,
// { status: "success" }
// >["data"]["services"];
type MachinesModel = Extract<
OperationResponse<"list_machines">,
{ status: "success" }
>["data"];
pyApi.open_file.receive((r) => {
if (r.op_key === "open_clan") {
console.log(r);
if (r.status === "error") return console.error(r.errors);
// pyApi.open_file.receive((r) => {
// if (r.op_key === "open_clan") {
// console.log(r);
// if (r.status === "error") return console.error(r.errors);
if (r.data) {
setCurrClanURI(r.data);
}
}
});
// if (r.data) {
// setCurrClanURI(r.data);
// }
// }
// });
export const MachineListView: Component = () => {
const [{ machines, loading }, { getMachines }] = useMachineContext();
// const [files, setFiles] = createSignal<FilesModel>([]);
const [files, setFiles] = createSignal<FilesModel>([]);
pyApi.get_directory.receive((r) => {
const { status } = r;
if (status === "error") return console.error(r.errors);
setFiles(r.data.files);
});
// pyApi.get_directory.receive((r) => {
// const { status } = r;
// if (status === "error") return console.error(r.errors);
// setFiles(r.data.files);
// });
const [services, setServices] = createSignal<ServiceModel>();
pyApi.show_mdns.receive((r) => {
const { status } = r;
if (status === "error") return console.error(r.errors);
setServices(r.data.services);
});
// const [services, setServices] = createSignal<ServiceModel>();
// pyApi.show_mdns.receive((r) => {
// const { status } = r;
// if (status === "error") return console.error(r.errors);
// setServices(r.data.services);
// });
createEffect(() => {
console.log(files());
});
const [machines, setMachines] = createSignal<MachinesModel>({});
const [loading, setLoading] = createSignal<boolean>(false);
const [data, setData] = createSignal<MachinesModel>({});
createEffect(() => {
if (route() === "machines") getMachines();
});
const unpackedMachines = () => Object.entries(data());
createEffect(() => {
const response = machines();
if (response?.status === "success") {
console.log(response.data);
setData(response.data);
toast.success("Machines loaded");
const listMachines = async () => {
const uri = activeURI();
if (!uri) {
return;
}
if (response?.status === "error") {
setData({});
console.error(response.errors);
toast.error("Error loading machines");
response.errors.forEach((error) =>
toast.error(
`${error.message}: ${error.description} From ${error.location}`
)
);
setLoading(true);
const response = await callApi("list_machines", {
flake_url: uri,
});
setLoading(false);
if (response.status === "success") {
setMachines(response.data);
}
};
createEffect(() => {
if (route() === "machines") listMachines();
});
const unpackedMachines = () => Object.entries(machines());
return (
<div class="max-w-screen-lg">
<div class="tooltip tooltip-bottom" data-tip="Open Clan">
<button
class="btn btn-ghost"
onClick={() =>
pyApi.open_file.dispatch({
file_request: {
title: "Open Clan",
mode: "select_folder",
},
op_key: "open_clan",
})
}
>
<span class="material-icons ">folder_open</span>
</button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Search install targets">
<button
class="btn btn-ghost"
onClick={() => pyApi.show_mdns.dispatch({})}
>
<span class="material-icons ">search</span>
</button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Open Clan"></div>
<div class="tooltip tooltip-bottom" data-tip="Refresh">
<button class="btn btn-ghost" onClick={() => getMachines()}>
<button class="btn btn-ghost" onClick={() => listMachines()}>
<span class="material-icons ">refresh</span>
</button>
</div>
<Show when={services()}>
{/* <Show when={services()}>
{(services) => (
<For each={Object.values(services())}>
{(service) => (
@ -163,7 +132,7 @@ export const MachineListView: Component = () => {
)}
</For>
)}
</Show>
</Show> */}
<Switch>
<Match when={loading()}>
{/* Loading skeleton */}

View File

@ -0,0 +1,98 @@
import { callApi } from "@/src/api";
import {
SubmitHandler,
createForm,
required,
setValue,
} from "@modular-forms/solid";
import { activeURI, setClanList, setActiveURI, setRoute } from "@/src/App";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type SettingsForm = {
base_dir: string | null;
};
export const registerClan = async () => {
try {
const loc = await callApi("open_file", {
file_request: { mode: "select_folder" },
});
console.log(loc);
if (loc.status === "success" && loc.data) {
// @ts-expect-error: data is a string
setClanList((s) => [...s, loc.data]);
setRoute((r) => {
if (r === "welcome") return "machines";
return r;
});
return loc.data;
}
} catch (e) {
//
}
};
export const Settings = () => {
const [formStore, { Form, Field }] = createForm<SettingsForm>({
initialValues: {
base_dir: activeURI(),
},
});
const handleSubmit: SubmitHandler<SettingsForm> = async (values, event) => {
//
};
return (
<div class="card card-normal">
<Form onSubmit={handleSubmit} shouldActive>
<div class="card-body">
<Field name="base_dir" validate={[required("Clan URI is required")]}>
{(field, props) => (
<label class="form-control w-full">
<div class="label">
<span class="label-text block after:ml-0.5 after:text-primary">
Directory
</span>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-primary">
<span class="material-icons">inventory</span>
</div>
<div class="stat-title">Clan URI</div>
<div
class="stat-value"
classList={{ "text-slate-500": !field.value }}
>
{field.value || "Not set"}
<button
class="btn btn-ghost mx-4"
onClick={async () => {
const location = await registerClan();
if (location) {
setActiveURI(location);
setValue(formStore, "base_dir", location);
}
}}
>
<span class="material-icons">edit</span>
</button>
</div>
<div class="stat-desc">Where the clan source resides</div>
</div>
</div>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
</div>
</Form>
</div>
);
};

View File

@ -0,0 +1,32 @@
import { setActiveURI, setRoute } from "@/src/App";
import { registerClan } from "../settings";
export const Welcome = () => {
return (
<div class="hero min-h-screen">
<div class="hero-content mb-32 text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Welcome to Clan</h1>
<p class="py-6">Own the services you use.</p>
<div class="flex flex-col items-start gap-2">
<button
class="btn btn-primary w-full"
onClick={() => setRoute("createClan")}
>
Build your own
</button>
<button
class="link w-full text-right text-primary"
onClick={async () => {
const uri = await registerClan();
if (uri) setActiveURI(uri);
}}
>
Or select folder
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
export function isValidHostname(value: string | null | undefined) {
if (typeof value !== "string") return false;
const validHostnameChars = /^[a-zA-Z0-9-.]{1,253}\.?$/g;
if (!validHostnameChars.test(value)) {
return false;
}
if (value.endsWith(".")) {
value = value.slice(0, value.length - 1);
}
if (value.length > 253) {
return false;
}
const labels = value.split(".");
const isValid = labels.every(function (label) {
const validLabelChars = /^([a-zA-Z0-9-]+)$/g;
const validLabel =
validLabelChars.test(label) &&
label.length < 64 &&
!label.startsWith("-") &&
!label.endsWith("-");
return validLabel;
});
return isValid;
}

View File

@ -16,7 +16,7 @@
npmDeps = pkgs.fetchNpmDeps {
src = ./app;
hash = "sha256-3LjcHh+jCuarh9XmS+mOv7xaGgAHxf3L7fWnxxmxUGQ=";
hash = "sha256-U8FwGL0FelUZwa8NjitfsFNDSofUPbp+nHrypeDj2Po=";
};
# The prepack script runs the build script, which we'd rather do in the build phase.
npmPackFlags = [ "--ignore-scripts" ];