Clan create: migrate to inventory #1732
@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Minimal inventory"
|
||||
"name": "clan-core"
|
||||
},
|
||||
"machines": {
|
||||
"minimal-inventory-machine": {
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
52
pkgs/webview-ui/app/package-lock.json
generated
52
pkgs/webview-ui/app/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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>,
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 {
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -3,3 +3,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
@ -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 */}
|
||||
|
98
pkgs/webview-ui/app/src/routes/settings/index.tsx
Normal file
98
pkgs/webview-ui/app/src/routes/settings/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
32
pkgs/webview-ui/app/src/routes/welcome/index.tsx
Normal file
32
pkgs/webview-ui/app/src/routes/welcome/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
pkgs/webview-ui/app/util.ts
Normal file
33
pkgs/webview-ui/app/util.ts
Normal 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;
|
||||
}
|
@ -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" ];
|
||||
|
Loading…
Reference in New Issue
Block a user