Merge pull request 'Clan create: migrate to inventory' (#1732) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-07-11 15:09:24 +00:00
28 changed files with 648 additions and 569 deletions

View File

@@ -1,6 +1,6 @@
{ {
"meta": { "meta": {
"name": "Minimal inventory" "name": "clan-core"
}, },
"machines": { "machines": {
"minimal-inventory-machine": { "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`. This will start the application in debug mode and link it to the web server running at `http://localhost:3000`.
# clan app (old) # clan app (old)
Provides users with the simple functionality to manage their locally registered clans. 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: def sanitize_string(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"') return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def dataclass_to_dict(obj: Any) -> Any: def dataclass_to_dict(obj: Any) -> Any:

View File

@@ -1,12 +1,12 @@
# !/usr/bin/env python3 # !/usr/bin/env python3
import argparse import argparse
import json
import os import os
from dataclasses import dataclass, fields from dataclasses import dataclass, fields
from pathlib import Path from pathlib import Path
from clan_cli.api import API from clan_cli.api import API
from clan_cli.arg_actions import AppendOptionAction from clan_cli.arg_actions import AppendOptionAction
from clan_cli.inventory import Inventory, InventoryMeta
from ..cmd import CmdOut, run from ..cmd import CmdOut, run
from ..errors import ClanError from ..errors import ClanError
@@ -24,19 +24,12 @@ class CreateClanResponse:
flake_update: CmdOut flake_update: CmdOut
@dataclass
class ClanMetaInfo:
name: str
description: str | None
icon: str | None
@dataclass @dataclass
class CreateOptions: class CreateOptions:
directory: Path | str directory: Path | str
# Metadata for the clan # Metadata for the clan
# Metadata can be shown with `clan show` # 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 # URL to the template to use. Defaults to the "minimal" template
template_url: str = minimal_template_url template_url: str = minimal_template_url
@@ -70,13 +63,7 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
) )
out = run(command, cwd=directory) out = run(command, cwd=directory)
# Write meta.json file if meta is provided ## Begin: setup git
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)
command = nix_shell(["nixpkgs#git"], ["git", "init"]) command = nix_shell(["nixpkgs#git"], ["git", "init"])
out = run(command, cwd=directory) out = run(command, cwd=directory)
cmd_responses["git init"] = out cmd_responses["git init"] = out
@@ -94,6 +81,14 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
) )
out = run(command, cwd=directory) out = run(command, cwd=directory)
cmd_responses["git config"] = out 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"] command = ["nix", "flake", "update"]
out = run(command, cwd=directory) out = run(command, cwd=directory)
@@ -118,7 +113,7 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"--meta", "--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, nargs=2,
metavar=("name", "value"), metavar=("name", "value"),
action=AppendOptionAction, action=AppendOptionAction,

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import os import os
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
from age_keys import SopsSetup from age_keys import SopsSetup
@@ -14,7 +16,7 @@ def def_value() -> defaultdict:
# allows defining nested dictionary in a single line # 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 @pytest.mark.impure

View File

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

View File

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

View File

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

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

View File

@@ -1,5 +1,6 @@
import { FromSchema } from "json-schema-to-ts"; import { FromSchema } from "json-schema-to-ts";
import { schema } from "@/api"; import { schema } from "@/api";
import { nanoid } from "nanoid";
export type API = FromSchema<typeof schema>; export type API = FromSchema<typeof schema>;
@@ -41,47 +42,44 @@ const operations = schema.properties;
const operationNames = Object.keys(operations) as OperationNames[]; const operationNames = Object.keys(operations) as OperationNames[];
type ObserverRegistry = { 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) => ({
...acc, ...acc,
[opName]: [], [opName]: {},
}), }),
{} as ObserverRegistry {} 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>( function createFunctions<K extends OperationNames>(
operationName: K operationName: K
): { ): {
dispatch: (args: OperationArgs<K>) => void; dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void) => void; receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
} { } {
return { return {
dispatch: (args: OperationArgs<K>) => { dispatch: (args: OperationArgs<K>) => {
// console.log(
// `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}`
// );
// Send the data to the gtk app // Send the data to the gtk app
window.webkit.messageHandlers.gtk.postMessage({ window.webkit.messageHandlers.gtk.postMessage({
method: operationName, method: operationName,
data: args, data: args,
}); });
}, },
receive: ( receive: (fn: (response: OperationResponse<K>) => void, id: string) => {
fn: (response: OperationResponse<K>) => void // @ts-expect-error: This should work although typescript doesn't let us write
// options?: ReceiveOptions registry[operationName][id] = fn;
) => {
obs[operationName].push(fn);
window.clan[operationName] = (s: string) => { 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 = { type PyApi = {
[K in OperationNames]: { [K in OperationNames]: {
dispatch: (args: OperationArgs<K>) => void; 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 = const deserialize =
<T>(fn: (response: T) => void) => <T>(fn: (response: T) => void) =>
(str: string) => { (str: string) => {
try { try {
fn(JSON.parse(str) as T); fn(JSON.parse(str) as T);
} catch (e) { } catch (e) {
console.log("Error parsing JSON: ", e);
console.log({ download: () => download("error.json", str) });
console.error(str); console.error(str);
alert(`Error parsing JSON: ${e}`); alert(`Error parsing JSON: ${e}`);
} }

View File

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

View File

@@ -3,3 +3,9 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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 = () => { export const Header = () => {
return ( return (
@@ -14,12 +14,12 @@ export const Header = () => {
</span> </span>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<a class="text-xl">{currClanURI() || "Clan"}</a> <a class="text-xl">{activeURI()}</a>
</div> </div>
<div class="flex-none"> <div class="flex-none">
<span class="tooltip tooltip-bottom" data-tip="Account"> <span class="tooltip tooltip-bottom" data-tip="Settings">
<button class="btn btn-square btn-ghost"> <button class="link" onClick={() => setRoute("settings")}>
<span class="material-icons">account_circle</span> <span class="material-icons">settings</span>
</button> </button>
</span> </span>
</div> </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 { Header } from "./header";
import { Sidebar } from "../Sidebar"; import { Sidebar } from "../Sidebar";
import { route, setRoute } from "../App"; import { route, setRoute } from "../App";
import { effect } from "solid-js/web";
interface LayoutProps { interface LayoutProps {
children: JSXElement; children: JSXElement;
} }
export const Layout: Component<LayoutProps> = (props) => { export const Layout: Component<LayoutProps> = (props) => {
effect(() => {
console.log(route());
});
return ( return (
<> <>
<div class="drawer bg-base-100 lg:drawer-open"> <div class="drawer bg-base-100 lg:drawer-open">
@@ -17,11 +21,16 @@ export const Layout: Component<LayoutProps> = (props) => {
class="drawer-toggle hidden" class="drawer-toggle hidden"
/> />
<div class="drawer-content"> <div class="drawer-content">
<Show when={route() !== "welcome"}>
<Header /> <Header />
</Show>
{props.children} {props.children}
</div> </div>
<div class="drawer-side z-40"> <div
class="drawer-side z-40"
classList={{ "!hidden": route() === "welcome" }}
>
<label <label
for="toplevel-drawer" for="toplevel-drawer"
aria-label="close sidebar" aria-label="close sidebar"

View File

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

View File

@@ -11,45 +11,91 @@ import {
import { import {
SubmitHandler, SubmitHandler,
createForm, createForm,
email,
required, required,
custom,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import toast from "solid-toast";
import { setActiveURI, setRoute } from "@/src/App";
interface ClanDetailsProps { interface ClanDetailsProps {
directory: string; directory: string;
} }
interface ClanFormProps { type CreateForm = Meta & {
directory?: string; template_url: string;
meta: ClanMeta;
actions: JSX.Element;
editable?: boolean;
}
export const ClanForm = (props: ClanFormProps) => {
const { meta, actions, editable = true, directory } = props;
const [formStore, { Form, Field }] = createForm<ClanMeta>({
initialValues: meta,
});
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 },
});
}
return;
}
});
console.log("submit", values);
}; };
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<CreateForm> = async (values, event) => {
const { template_url, ...meta } = values;
pyApi.open_file.dispatch({
file_request: {
mode: "save",
},
op_key: "create_clan",
});
// 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 ( return (
<div class="card card-compact w-96 bg-base-100 shadow-xl"> <div class="card card-normal">
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit} shouldActive>
<Field name="icon"> <Field name="icon">
{(field, props) => ( {(field, props) => (
<> <>
@@ -71,31 +117,16 @@ export const ClanForm = (props: ClanFormProps) => {
)} )}
</Show> </Show>
</figure> </figure>
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">Select icon</span>
</div>
<input
type="file"
class="file-input file-input-bordered w-full max-w-xs"
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
</> </>
)} )}
</Field> </Field>
<div class="card-body">
<div class="card-body"> <div class="card-body">
<Field <Field
name="name" name="name"
validate={[required("Please enter a unique name for the clan.")]} validate={[required("Please enter a unique name for the clan.")]}
> >
{(field, props) => ( {(field, props) => (
<label class="form-control w-full max-w-xs"> <label class="form-control w-full">
<div class="label"> <div class="label">
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']"> <span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
Name Name
@@ -104,10 +135,9 @@ export const ClanForm = (props: ClanFormProps) => {
<input <input
{...props} {...props}
type="email"
required required
placeholder="your.mail@example.com" placeholder="Clan Name"
class="input w-full max-w-xs" class="input input-bordered"
classList={{ "input-error": !!field.error }} classList={{ "input-error": !!field.error }}
value={field.value} value={field.value}
/> />
@@ -121,7 +151,7 @@ export const ClanForm = (props: ClanFormProps) => {
</Field> </Field>
<Field name="description"> <Field name="description">
{(field, props) => ( {(field, props) => (
<label class="form-control w-full max-w-xs"> <label class="form-control w-full">
<div class="label"> <div class="label">
<span class="label-text">Description</span> <span class="label-text">Description</span>
</div> </div>
@@ -131,7 +161,7 @@ export const ClanForm = (props: ClanFormProps) => {
required required
type="text" type="text"
placeholder="Some words about your clan" placeholder="Some words about your clan"
class="input w-full max-w-xs" class="input input-bordered"
classList={{ "input-error": !!field.error }} classList={{ "input-error": !!field.error }}
value={field.value || ""} value={field.value || ""}
/> />
@@ -143,96 +173,50 @@ export const ClanForm = (props: ClanFormProps) => {
</label> </label>
)} )}
</Field> </Field>
{actions} <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> </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> </div>
</Form> </Form>
</div> </div>
); );
}; };
// export const EditMetaFields = (props: MetaFieldsProps) => { type Meta = Extract<
// const { meta, editable, actions, directory } = props;
// const [editing, setEditing] = createSignal<
// keyof MetaFieldsProps["meta"] | null
// >(null);
// return (
// );
// };
type ClanMeta = Extract<
OperationResponse<"show_clan_meta">, OperationResponse<"show_clan_meta">,
{ status: "success" } { status: "success" }
>["data"]; >["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 { ClanForm } from "./clanDetails";
import { Match, Switch, createEffect, createSignal } from "solid-js";
import toast from "solid-toast";
import { ClanDetails, ClanForm } from "./clanDetails";
export const clan = () => { export const CreateClan = () => {
const [mode, setMode] = createSignal<"init" | "open" | "create">("init");
const [clanDir, setClanDir] = createSignal<string | null>(null);
createEffect(() => {
console.log(mode());
});
return ( return (
<div> <div>
<Switch fallback={"invalid"}> <ClanForm />
<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>
</div> </div>
); );
}; };

View File

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

View File

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

View File

@@ -7,117 +7,86 @@ import {
createSignal, createSignal,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import { useMachineContext } from "../../Config"; import { activeURI, route, setActiveURI } from "@/src/App";
import { route, setCurrClanURI } from "@/src/App"; import { OperationResponse, callApi, pyApi } from "@/src/api";
import { OperationResponse, pyApi } from "@/src/api";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem"; import { MachineListItem } from "@/src/components/MachineListItem";
type FilesModel = Extract< // type FilesModel = Extract<
OperationResponse<"get_directory">, // OperationResponse<"get_directory">,
{ status: "success" } // { status: "success" }
>["data"]["files"]; // >["data"]["files"];
type ServiceModel = Extract< // type ServiceModel = Extract<
OperationResponse<"show_mdns">, // OperationResponse<"show_mdns">,
{ status: "success" } // { status: "success" }
>["data"]["services"]; // >["data"]["services"];
type MachinesModel = Extract< type MachinesModel = Extract<
OperationResponse<"list_machines">, OperationResponse<"list_machines">,
{ status: "success" } { status: "success" }
>["data"]; >["data"];
pyApi.open_file.receive((r) => { // pyApi.open_file.receive((r) => {
if (r.op_key === "open_clan") { // if (r.op_key === "open_clan") {
console.log(r); // console.log(r);
if (r.status === "error") return console.error(r.errors); // if (r.status === "error") return console.error(r.errors);
if (r.data) { // if (r.data) {
setCurrClanURI(r.data); // setCurrClanURI(r.data);
} // }
} // }
}); // });
export const MachineListView: Component = () => { export const MachineListView: Component = () => {
const [{ machines, loading }, { getMachines }] = useMachineContext(); // const [files, setFiles] = createSignal<FilesModel>([]);
const [files, setFiles] = createSignal<FilesModel>([]); // pyApi.get_directory.receive((r) => {
pyApi.get_directory.receive((r) => { // const { status } = r;
const { status } = r; // if (status === "error") return console.error(r.errors);
if (status === "error") return console.error(r.errors); // setFiles(r.data.files);
setFiles(r.data.files); // });
});
const [services, setServices] = createSignal<ServiceModel>(); // const [services, setServices] = createSignal<ServiceModel>();
pyApi.show_mdns.receive((r) => { // pyApi.show_mdns.receive((r) => {
const { status } = r; // const { status } = r;
if (status === "error") return console.error(r.errors); // if (status === "error") return console.error(r.errors);
setServices(r.data.services); // setServices(r.data.services);
}); // });
createEffect(() => { const [machines, setMachines] = createSignal<MachinesModel>({});
console.log(files()); const [loading, setLoading] = createSignal<boolean>(false);
});
const [data, setData] = createSignal<MachinesModel>({}); const listMachines = async () => {
createEffect(() => { const uri = activeURI();
if (route() === "machines") getMachines(); if (!uri) {
}); return;
const unpackedMachines = () => Object.entries(data());
createEffect(() => {
const response = machines();
if (response?.status === "success") {
console.log(response.data);
setData(response.data);
toast.success("Machines loaded");
}
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 ( return (
<div class="max-w-screen-lg"> <div class="max-w-screen-lg">
<div class="tooltip tooltip-bottom" data-tip="Open Clan"> <div class="tooltip tooltip-bottom" data-tip="Open Clan"></div>
<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="Refresh"> <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> <span class="material-icons ">refresh</span>
</button> </button>
</div> </div>
<Show when={services()}> {/* <Show when={services()}>
{(services) => ( {(services) => (
<For each={Object.values(services())}> <For each={Object.values(services())}>
{(service) => ( {(service) => (
@@ -163,7 +132,7 @@ export const MachineListView: Component = () => {
)} )}
</For> </For>
)} )}
</Show> </Show> */}
<Switch> <Switch>
<Match when={loading()}> <Match when={loading()}>
{/* Loading skeleton */} {/* 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 { npmDeps = pkgs.fetchNpmDeps {
src = ./app; 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. # The prepack script runs the build script, which we'd rather do in the build phase.
npmPackFlags = [ "--ignore-scripts" ]; npmPackFlags = [ "--ignore-scripts" ];