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(
|
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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user