Clan-app: connection check & show remote devices

This commit is contained in:
Johannes Kirschbauer
2024-08-16 12:18:21 +02:00
parent 340babd348
commit 92e3c3f40b
5 changed files with 174 additions and 8 deletions

View File

@@ -162,6 +162,11 @@ def get_inventory(base_path: str) -> Inventory:
def set_service_instance( def set_service_instance(
base_path: str, module_name: str, instance_name: str, config: dict[str, Any] base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
) -> None: ) -> None:
"""
A function that allows to set any service instance in the inventory.
Takes any untyped dict. The dict is then checked and converted into the correct type using the type hints of the service.
If any conversion error occurs, the function will raise an error.
"""
service_keys = get_type_hints(Service).keys() service_keys = get_type_hints(Service).keys()
if module_name not in service_keys: if module_name not in service_keys:

View File

@@ -3,12 +3,13 @@ import json
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal
from clan_cli.api import API from clan_cli.api import API
from clan_cli.cmd import run_no_stdout from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanError from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import Machine, load_inventory_eval from clan_cli.inventory import Machine, load_inventory_eval
from clan_cli.nix import nix_eval from clan_cli.nix import nix_eval, nix_shell
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -66,6 +67,52 @@ def list_nixos_machines(flake_url: str | Path) -> list[str]:
raise ClanError(f"Error decoding machines from flake: {e}") raise ClanError(f"Error decoding machines from flake: {e}")
@dataclass
class ConnectionOptions:
keyfile: str | None = None
timeout: int = 2
@API.register
def check_machine_online(
flake_url: str | Path, machine_name: str, opts: ConnectionOptions | None
) -> Literal["Online", "Offline"]:
machine = load_inventory_eval(flake_url).machines.get(machine_name)
if not machine:
raise ClanError(f"Machine {machine_name} not found in inventory")
hostname = machine.deploy.targetHost
if not hostname:
raise ClanError(f"Machine {machine_name} does not specify a targetHost")
timeout = opts.timeout if opts and opts.timeout else 2
cmd = nix_shell(
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if hostname else [])],
[
"ssh",
*(["-i", f"{opts.keyfile}"] if opts and opts.keyfile else []),
# Disable strict host key checking
"-o StrictHostKeyChecking=no",
# Disable known hosts file
"-o UserKnownHostsFile=/dev/null",
f"-o ConnectTimeout={timeout}",
f"{hostname}",
"true",
"&> /dev/null",
],
)
try:
proc = run_no_stdout(cmd)
if proc.returncode != 0:
return "Offline"
return "Online"
except ClanCmdError:
return "Offline"
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:
flake_path = args.flake.path flake_path = args.flake.path
for name in list_nixos_machines(flake_path): for name in list_nixos_machines(flake_path):

View File

@@ -11,7 +11,7 @@ export const BlockDevicesView: Component = () => {
} = createQuery(() => ({ } = createQuery(() => ({
queryKey: ["block_devices"], queryKey: ["block_devices"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("show_block_devices", {}); const result = await callApi("show_block_devices", { options: {} });
if (result.status === "error") throw new Error("Failed to fetch data"); if (result.status === "error") throw new Error("Failed to fetch data");
return result.data; return result.data;

View File

@@ -45,7 +45,7 @@ export const Flash = () => {
/* ==== WIFI NETWORK ==== */ /* ==== WIFI NETWORK ==== */
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]); const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>( const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
[], []
); );
createEffect(() => { createEffect(() => {
@@ -67,7 +67,7 @@ export const Flash = () => {
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index); const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
setWifiNetworks(updatedNetworks); setWifiNetworks(updatedNetworks);
const updatedVisibility = passwordVisibility().filter( const updatedVisibility = passwordVisibility().filter(
(_, i) => i !== index, (_, i) => i !== index
); );
setPasswordVisibility(updatedVisibility); setPasswordVisibility(updatedVisibility);
setValue(formStore, "wifi", updatedNetworks); setValue(formStore, "wifi", updatedNetworks);
@@ -83,7 +83,9 @@ export const Flash = () => {
const deviceQuery = createQuery(() => ({ const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"], queryKey: ["block_devices"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("show_block_devices", { options: {} }); const result = await callApi("show_block_devices", {
options: {},
});
if (result.status === "error") throw new Error("Failed to fetch data"); if (result.status === "error") throw new Error("Failed to fetch data");
return result.data; return result.data;
}, },

View File

@@ -1,14 +1,25 @@
import { callApi } from "@/src/api"; import { callApi } from "@/src/api";
import { activeURI } from "@/src/App"; import { activeURI } from "@/src/App";
import { SelectInput } from "@/src/components/SelectInput";
import { createForm } from "@modular-forms/solid";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { createSignal, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
type InstallForm = {
disk: string;
};
export const MachineDetails = () => { export const MachineDetails = () => {
const params = useParams(); const params = useParams();
const query = createQuery(() => ({ const query = createQuery(() => ({
queryKey: [activeURI(), "machine", params.id], queryKey: [
activeURI(),
"machine",
params.id,
"get_inventory_machine_details",
],
queryFn: async () => { queryFn: async () => {
const curr = activeURI(); const curr = activeURI();
if (curr) { if (curr) {
@@ -24,12 +35,66 @@ export const MachineDetails = () => {
const [sshKey, setSshKey] = createSignal<string>(); const [sshKey, setSshKey] = createSignal<string>();
const [formStore, { Form, Field }] = createForm<InstallForm>({});
const handleSubmit = async (values: InstallForm) => {
return null;
};
const targetHost = () => query?.data?.machine.deploy.targetHost;
const remoteDiskQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "show_block_devices"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_block_devices", {
options: {
hostname: targetHost(),
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
const onlineStatusQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "check_machine_online"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("check_machine_online", {
flake_url: curr,
machine_name: params.id,
opts: {
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
refetchInterval: 5000,
}));
return ( return (
<div> <div>
{query.isLoading && <span class="loading loading-bars" />} {query.isLoading && <span class="loading loading-bars" />}
<Show when={!query.isLoading && query.data}> <Show when={!query.isLoading && query.data}>
{(data) => ( {(data) => (
<div class="grid grid-cols-2 gap-2 text-lg"> <div class="grid grid-cols-2 gap-2 text-lg">
<Show when={onlineStatusQuery.isFetching} fallback={<span></span>}>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
<span
class="badge badge-outline text-lg"
classList={{
"badge-primary": onlineStatusQuery.data === "Online",
}}
>
{onlineStatusQuery.data}
</span>
<label class="justify-self-end font-light">Name</label> <label class="justify-self-end font-light">Name</label>
<span>{data().machine.name}</span> <span>{data().machine.name}</span>
<span class="justify-self-end font-light">description</span> <span class="justify-self-end font-light">description</span>
@@ -65,6 +130,7 @@ export const MachineDetails = () => {
<span class="justify-self-end font-light">has hw spec</span> <span class="justify-self-end font-light">has hw spec</span>
<span>{data().has_hw_specs ? "Yes" : "Not yet"}</span> <span>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
<div class="col-span-2 justify-self-center"> <div class="col-span-2 justify-self-center">
<button <button
class="btn btn-primary join-item btn-sm" class="btn btn-primary join-item btn-sm"
@@ -102,6 +168,52 @@ export const MachineDetails = () => {
Generate HW Spec Generate HW Spec
</button> </button>
</div> </div>
<button
class="btn self-end"
onClick={() => remoteDiskQuery.refetch()}
>
Refresh remote disks
</button>
<Form onSubmit={handleSubmit} class="w-full">
<Field name="disk">
{(field, props) => (
<SelectInput
formStore={formStore}
selectProps={props}
label="Remote Disk to use"
value={String(field.value)}
error={field.error}
required
options={
<Show when={remoteDiskQuery.data}>
{(disks) => (
<>
<option disabled>
Select the boot disk of the remote machine
</option>
<For each={disks().blockdevices}>
{(dev) => (
<option value={dev.name}>
{dev.name}
{" -- "}
{dev.size}
{"bytes @"}
{
query.data?.machine.deploy.targetHost?.split(
"@",
)?.[1]
}
</option>
)}
</For>
</>
)}
</Show>
}
/>
)}
</Field>
</Form>
</div> </div>
)} )}
</Show> </Show>