Clan-app: connection check & show remote devices

This commit is contained in:
Johannes Kirschbauer
2024-08-16 12:18:21 +02:00
parent 76ca85ac73
commit cabdbe5ecd
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(
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
) -> 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()
if module_name not in service_keys:

View File

@@ -3,12 +3,13 @@ import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from clan_cli.api import API
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.nix import nix_eval
from clan_cli.nix import nix_eval, nix_shell
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}")
@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:
flake_path = args.flake.path
for name in list_nixos_machines(flake_path):

View File

@@ -11,7 +11,7 @@ export const BlockDevicesView: Component = () => {
} = createQuery(() => ({
queryKey: ["block_devices"],
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");
return result.data;

View File

@@ -45,7 +45,7 @@ export const Flash = () => {
/* ==== WIFI NETWORK ==== */
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
[],
[]
);
createEffect(() => {
@@ -67,7 +67,7 @@ export const Flash = () => {
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
setWifiNetworks(updatedNetworks);
const updatedVisibility = passwordVisibility().filter(
(_, i) => i !== index,
(_, i) => i !== index
);
setPasswordVisibility(updatedVisibility);
setValue(formStore, "wifi", updatedNetworks);
@@ -83,7 +83,9 @@ export const Flash = () => {
const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"],
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");
return result.data;
},

View File

@@ -1,14 +1,25 @@
import { callApi } from "@/src/api";
import { activeURI } from "@/src/App";
import { SelectInput } from "@/src/components/SelectInput";
import { createForm } from "@modular-forms/solid";
import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, Show } from "solid-js";
import { createSignal, For, Show } from "solid-js";
import toast from "solid-toast";
type InstallForm = {
disk: string;
};
export const MachineDetails = () => {
const params = useParams();
const query = createQuery(() => ({
queryKey: [activeURI(), "machine", params.id],
queryKey: [
activeURI(),
"machine",
params.id,
"get_inventory_machine_details",
],
queryFn: async () => {
const curr = activeURI();
if (curr) {
@@ -24,12 +35,66 @@ export const MachineDetails = () => {
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 (
<div>
{query.isLoading && <span class="loading loading-bars" />}
<Show when={!query.isLoading && query.data}>
{(data) => (
<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>
<span>{data().machine.name}</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>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
<div class="col-span-2 justify-self-center">
<button
class="btn btn-primary join-item btn-sm"
@@ -102,6 +168,52 @@ export const MachineDetails = () => {
Generate HW Spec
</button>
</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>
)}
</Show>