Clan-app: connection check & show remote devices
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user