Befor fixing linting problem

This commit is contained in:
2023-10-22 21:03:06 +02:00
parent 545d389df0
commit c7c47b6527
87 changed files with 703 additions and 3929 deletions

View File

@@ -2,6 +2,7 @@
source_up
if type nix_direnv_watch_file &>/dev/null; then
nix_direnv_watch_file flake-module.nix
nix_direnv_watch_file default.nix

View File

@@ -1,11 +1,15 @@
import argparse
import logging
import sys
from types import ModuleType
from typing import Optional
from . import config, flakes, join, machines, secrets, vms, webui
from .custom_logger import register
from .ssh import cli as ssh_cli
log = logging.getLogger(__name__)
argcomplete: Optional[ModuleType] = None
try:
import argcomplete # type: ignore[no-redef]
@@ -52,6 +56,10 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser:
parser_vms = subparsers.add_parser("vms", help="manage virtual machines")
vms.register_parser(parser_vms)
# if args.debug:
register(logging.DEBUG)
log.debug("Debug log activated")
if argcomplete:
argcomplete.autocomplete(parser)

View File

@@ -11,9 +11,9 @@ from typing import Any, Optional, Tuple, get_origin
from clan_cli.dirs import machine_settings_file, specific_flake_dir
from clan_cli.errors import ClanError
from clan_cli.flakes.types import FlakeName
from clan_cli.git import commit_file
from clan_cli.nix import nix_eval
from clan_cli.types import FlakeName
script_dir = Path(__file__).parent
@@ -161,7 +161,11 @@ def read_machine_option_value(
def get_or_set_option(args: argparse.Namespace) -> None:
if args.value == []:
print(read_machine_option_value(args.machine, args.option, args.show_trace))
print(
read_machine_option_value(
args.flake, args.machine, args.option, args.show_trace
)
)
else:
# load options
if args.options_file is None:
@@ -308,11 +312,6 @@ def register_parser(
# inject callback function to process the input later
parser.set_defaults(func=get_or_set_option)
parser.add_argument(
"flake",
type=str,
help="name of the flake to set machine options for",
)
parser.add_argument(
"--machine",
"-m",
@@ -356,6 +355,11 @@ def register_parser(
nargs="*",
help="option value to set (if omitted, the current value is printed)",
)
parser.add_argument(
"flake",
type=str,
help="name of the flake to set machine options for",
)
def main(argv: Optional[list[str]] = None) -> None:

View File

@@ -14,7 +14,7 @@ from clan_cli.dirs import (
from clan_cli.git import commit_file, find_git_repo_root
from clan_cli.nix import nix_eval
from ..flakes.types import FlakeName
from ..types import FlakeName
def config_for_machine(flake_name: FlakeName, machine_name: str) -> dict:

View File

@@ -1,5 +1,7 @@
import inspect
import logging
from typing import Any
from pathlib import Path
from typing import Any, Callable
grey = "\x1b[38;20m"
yellow = "\x1b[33;20m"
@@ -9,11 +11,20 @@ green = "\u001b[32m"
blue = "\u001b[34m"
def get_formatter(color: str) -> logging.Formatter:
reset = "\x1b[0m"
return logging.Formatter(
f"{color}%(levelname)s{reset}:(%(filename)s:%(lineno)d): %(message)s"
)
def get_formatter(color: str) -> Callable[[logging.LogRecord, bool], logging.Formatter]:
def myformatter(
record: logging.LogRecord, with_location: bool
) -> logging.Formatter:
reset = "\x1b[0m"
filepath = Path(record.pathname).resolve()
if not with_location:
return logging.Formatter(f"{color}%(levelname)s{reset}: %(message)s")
return logging.Formatter(
f"{color}%(levelname)s{reset}: %(message)s\n {filepath}:%(lineno)d::%(funcName)s\n"
)
return myformatter
FORMATTER = {
@@ -26,12 +37,34 @@ FORMATTER = {
class CustomFormatter(logging.Formatter):
def format(self, record: Any) -> str:
return FORMATTER[record.levelno].format(record)
def format(self, record: logging.LogRecord) -> str:
return FORMATTER[record.levelno](record, True).format(record)
class ThreadFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return FORMATTER[record.levelno](record, False).format(record)
def get_caller() -> str:
frame = inspect.currentframe()
if frame is None:
return "unknown"
caller_frame = frame.f_back
if caller_frame is None:
return "unknown"
caller_frame = caller_frame.f_back
if caller_frame is None:
return "unknown"
frame_info = inspect.getframeinfo(caller_frame)
ret = f"{frame_info.filename}:{frame_info.lineno}::{frame_info.function}"
return ret
def register(level: Any) -> None:
ch = logging.StreamHandler()
ch.setLevel(level)
ch.setFormatter(CustomFormatter())
logging.basicConfig(level=level, handlers=[ch])
handler = logging.StreamHandler()
handler.setLevel(level)
handler.setFormatter(CustomFormatter())
logger = logging.getLogger("registerHandler")
logger.addHandler(handler)
# logging.basicConfig(level=level, handlers=[handler])

View File

@@ -0,0 +1,66 @@
from typing import Dict, Optional, Tuple, Callable, Any, Mapping, List
from pathlib import Path
import ipdb
import os
import stat
import subprocess
from .dirs import find_git_repo_root
import multiprocessing as mp
from .types import FlakeName
import logging
import sys
import shlex
import time
log = logging.getLogger(__name__)
def command_exec(cmd: List[str], work_dir:Path, env: Dict[str, str]) -> None:
subprocess.run(cmd, check=True, env=env, cwd=work_dir.resolve())
def repro_env_break(work_dir: Path, env: Optional[Dict[str, str]] = None, cmd: Optional[List[str]] = None) -> None:
if env is None:
env = os.environ.copy()
else:
env = env.copy()
# Error checking
if "bash" in env["SHELL"]:
raise Exception("I assumed you use zsh, not bash")
# Cmd appending
args = ["xterm", "-e", "zsh", "-df"]
if cmd is not None:
mycommand = shlex.join(cmd)
write_command(mycommand, work_dir / "cmd.sh")
print(f"Adding to zsh history the command: {mycommand}", file=sys.stderr)
proc = spawn_process(func=command_exec, cmd=args, work_dir=work_dir, env=env)
try:
ipdb.set_trace()
finally:
proc.terminate()
def write_command(command: str, loc:Path) -> None:
with open(loc, "w") as f:
f.write("#!/usr/bin/env bash\n")
f.write(command)
st = os.stat(loc)
os.chmod(loc, st.st_mode | stat.S_IEXEC)
def spawn_process(func: Callable, **kwargs:Any) -> mp.Process:
mp.set_start_method(method="spawn")
proc = mp.Process(target=func, kwargs=kwargs)
proc.start()
return proc
def dump_env(env: Dict[str, str], loc: Path) -> None:
cenv = env.copy()
with open(loc, "w") as f:
f.write("#!/usr/bin/env bash\n")
for k, v in cenv.items():
if v.count('\n') > 0 or v.count("\"") > 0 or v.count("'") > 0:
continue
f.write(f"export {k}='{v}'\n")
st = os.stat(loc)
os.chmod(loc, st.st_mode | stat.S_IEXEC)

View File

@@ -1,10 +1,13 @@
import logging
import os
import sys
from pathlib import Path
from typing import Optional
from .errors import ClanError
from .flakes.types import FlakeName
from .types import FlakeName
log = logging.getLogger(__name__)
def _get_clan_flake_toplevel() -> Path:
@@ -51,28 +54,31 @@ def user_data_dir() -> Path:
def clan_data_dir() -> Path:
path = user_data_dir() / "clan"
if not path.exists():
path.mkdir()
log.debug(f"Creating path with parents {path}")
path.mkdir(parents=True)
return path.resolve()
def clan_config_dir() -> Path:
path = user_config_dir() / "clan"
if not path.exists():
path.mkdir()
log.debug(f"Creating path with parents {path}")
path.mkdir(parents=True)
return path.resolve()
def clan_flakes_dir() -> Path:
path = clan_data_dir() / "flake"
if not path.exists():
path.mkdir()
log.debug(f"Creating path with parents {path}")
path.mkdir(parents=True)
return path.resolve()
def specific_flake_dir(flake_name: FlakeName) -> Path:
flake_dir = clan_flakes_dir() / flake_name
if not flake_dir.exists():
raise ClanError(f"Flake {flake_name} does not exist")
raise ClanError(f"Flake '{flake_name}' does not exist")
return flake_dir

View File

@@ -8,16 +8,20 @@ from pydantic.tools import parse_obj_as
from ..async_cmd import CmdOut, run, runforcli
from ..dirs import clan_flakes_dir
from ..errors import ClanError
from ..nix import nix_command, nix_shell
DEFAULT_URL: AnyUrl = parse_obj_as(
AnyUrl, "git+https://git.clan.lol/clan/clan-core#new-clan"
AnyUrl,
"git+https://git.clan.lol/clan/clan-core?ref=Qubasa-main#new-clan", # TODO: Change me back to main branch
)
async def create_flake(directory: Path, url: AnyUrl) -> Dict[str, CmdOut]:
if not directory.exists():
directory.mkdir()
else:
raise ClanError(f"Flake at '{directory}' already exists")
response = {}
command = nix_command(
[
@@ -27,27 +31,27 @@ async def create_flake(directory: Path, url: AnyUrl) -> Dict[str, CmdOut]:
url,
]
)
out = await run(command, directory)
out = await run(command, cwd=directory)
response["flake init"] = out
command = nix_shell(["git"], ["git", "init"])
out = await run(command, directory)
out = await run(command, cwd=directory)
response["git init"] = out
command = nix_shell(["git"], ["git", "add", "."])
out = await run(command, directory)
out = await run(command, cwd=directory)
response["git add"] = out
command = nix_shell(["git"], ["git", "config", "user.name", "clan-tool"])
out = await run(command, directory)
out = await run(command, cwd=directory)
response["git config"] = out
command = nix_shell(["git"], ["git", "config", "user.email", "clan@example.com"])
out = await run(command, directory)
out = await run(command, cwd=directory)
response["git config"] = out
command = nix_shell(["git"], ["git", "commit", "-a", "-m", "Initial commit"])
out = await run(command, directory)
out = await run(command, cwd=directory)
response["git commit"] = out
return response
@@ -55,7 +59,7 @@ async def create_flake(directory: Path, url: AnyUrl) -> Dict[str, CmdOut]:
def create_flake_command(args: argparse.Namespace) -> None:
flake_dir = clan_flakes_dir() / args.name
runforcli(create_flake, flake_dir, DEFAULT_URL)
runforcli(create_flake, flake_dir, args.url)
# takes a (sub)parser and configures it
@@ -65,5 +69,11 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
type=str,
help="name for the flake",
)
parser.add_argument(
"--url",
type=str,
help="url for the flake",
default=DEFAULT_URL,
)
# parser.add_argument("name", type=str, help="name of the flake")
parser.set_defaults(func=create_flake_command)

View File

@@ -1,3 +0,0 @@
from typing import NewType
FlakeName = NewType("FlakeName", str)

View File

@@ -5,14 +5,16 @@ from typing import Dict
from ..async_cmd import CmdOut, run, runforcli
from ..dirs import specific_flake_dir, specific_machine_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..nix import nix_shell
from ..types import FlakeName
log = logging.getLogger(__name__)
async def create_machine(flake_name: FlakeName, machine_name: str) -> Dict[str, CmdOut]:
folder = specific_machine_dir(flake_name, machine_name)
if folder.exists():
raise ClanError(f"Machine '{machine_name}' already exists")
folder.mkdir(parents=True, exist_ok=True)
# create empty settings.json file inside the folder

View File

@@ -1,5 +1,5 @@
from ..dirs import specific_machine_dir
from ..flakes.types import FlakeName
from ..types import FlakeName
def machine_has_fact(flake_name: FlakeName, machine: str, fact: str) -> bool:

View File

@@ -3,7 +3,7 @@ import logging
import os
from ..dirs import machines_dir
from ..flakes.types import FlakeName
from ..types import FlakeName
from .types import validate_hostname
log = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@ from typing import Callable
from ..dirs import specific_flake_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..types import FlakeName
def get_sops_folder(flake_name: FlakeName) -> Path:

View File

@@ -3,8 +3,8 @@ import os
from pathlib import Path
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..machines.types import machine_name_type, validate_hostname
from ..types import FlakeName
from . import secrets
from .folders import (
sops_groups_folder,
@@ -204,9 +204,17 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the command to run",
required=True,
)
# List groups
list_parser = subparser.add_parser("list", help="list groups")
list_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
list_parser.set_defaults(func=list_command)
# Add user
add_machine_parser = subparser.add_parser(
"add-machine", help="add a machine to group"
)
@@ -214,8 +222,14 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
add_machine_parser.add_argument(
"machine", help="the name of the machines to add", type=machine_name_type
)
add_machine_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_machine_parser.set_defaults(func=add_machine_command)
# Remove machine
remove_machine_parser = subparser.add_parser(
"remove-machine", help="remove a machine from group"
)
@@ -223,15 +237,27 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
remove_machine_parser.add_argument(
"machine", help="the name of the machines to remove", type=machine_name_type
)
remove_machine_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_machine_parser.set_defaults(func=remove_machine_command)
# Add user
add_user_parser = subparser.add_parser("add-user", help="add a user to group")
add_group_argument(add_user_parser)
add_user_parser.add_argument(
"user", help="the name of the user to add", type=user_name_type
)
add_user_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_user_parser.set_defaults(func=add_user_command)
# Remove user
remove_user_parser = subparser.add_parser(
"remove-user", help="remove a user from group"
)
@@ -239,8 +265,14 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
remove_user_parser.add_argument(
"user", help="the name of the user to remove", type=user_name_type
)
remove_user_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_user_parser.set_defaults(func=remove_user_command)
# Add secret
add_secret_parser = subparser.add_parser(
"add-secret", help="allow a user to access a secret"
)
@@ -250,8 +282,14 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
add_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
add_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_secret_parser.set_defaults(func=add_secret_command)
# Remove secret
remove_secret_parser = subparser.add_parser(
"remove-secret", help="remove a group's access to a secret"
)
@@ -261,4 +299,9 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
remove_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
remove_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_secret_parser.set_defaults(func=remove_secret_command)

View File

@@ -1,7 +1,7 @@
import argparse
from ..flakes.types import FlakeName
from ..machines.types import machine_name_type, validate_hostname
from ..types import FlakeName
from . import secrets
from .folders import list_objects, remove_object, sops_machines_folder
from .sops import read_key, write_key
@@ -96,11 +96,6 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
action="store_true",
default=False,
)
add_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
@@ -109,6 +104,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="public key or private key of the user",
type=public_or_private_age_key_type,
)
add_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_parser.set_defaults(func=add_command)
# Parser
@@ -125,46 +125,46 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
# Parser
remove_parser = subparser.add_parser("remove", help="remove a machine")
remove_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
remove_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
remove_parser.set_defaults(func=remove_command)
# Parser
add_secret_parser = subparser.add_parser(
"add-secret", help="allow a machine to access a secret"
)
add_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_secret_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
add_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_secret_parser.set_defaults(func=add_secret_command)
# Parser
remove_secret_parser = subparser.add_parser(
"remove-secret", help="remove a group's access to a secret"
)
remove_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_secret_parser.add_argument(
"machine", help="the name of the group", type=machine_name_type
)
remove_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
remove_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_secret_parser.set_defaults(func=remove_secret_command)

View File

@@ -8,7 +8,7 @@ from typing import IO
from .. import tty
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..types import FlakeName
from .folders import (
list_objects,
sops_groups_folder,
@@ -253,24 +253,24 @@ def rename_command(args: argparse.Namespace) -> None:
def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
parser_list = subparser.add_parser("list", help="list secrets")
parser_list.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_list.set_defaults(func=list_command)
parser_get = subparser.add_parser("get", help="get a secret")
add_secret_argument(parser_get)
parser_get.set_defaults(func=get_command)
parser_get.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_get.set_defaults(func=get_command)
parser_set = subparser.add_parser("set", help="set a secret")
add_secret_argument(parser_set)
parser_set.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_set.add_argument(
"--group",
type=str,
@@ -299,13 +299,28 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=False,
help="edit the secret with $EDITOR instead of pasting it",
)
parser_set.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_set.set_defaults(func=set_command)
parser_rename = subparser.add_parser("rename", help="rename a secret")
add_secret_argument(parser_rename)
parser_rename.add_argument("new_name", type=str, help="the new name of the secret")
parser_rename.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_rename.set_defaults(func=rename_command)
parser_remove = subparser.add_parser("remove", help="remove a secret")
add_secret_argument(parser_remove)
parser_remove.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
parser_remove.set_defaults(func=remove_command)

View File

@@ -9,8 +9,8 @@ from typing import IO, Iterator
from ..dirs import user_config_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..nix import nix_shell
from ..types import FlakeName
from .folders import sops_machines_folder, sops_users_folder

View File

@@ -6,17 +6,19 @@ import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
import logging
from clan_cli.nix import nix_shell
from ..dirs import specific_flake_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..types import FlakeName
from .folders import sops_secrets_folder
from .machines import add_machine, has_machine
from .secrets import decrypt_secret, encrypt_secret, has_secret
from .sops import generate_private_key
log = logging.getLogger(__name__)
def generate_host_key(flake_name: FlakeName, machine_name: str) -> None:
if has_machine(flake_name, machine_name):
@@ -95,6 +97,7 @@ def generate_secrets_from_nix(
) -> None:
generate_host_key(flake_name, machine_name)
errors = {}
log.debug("Generating secrets for machine %s and flake %s", machine_name, flake_name)
with TemporaryDirectory() as d:
# if any of the secrets are missing, we regenerate all connected facts/secrets
for secret_group, secret_options in secret_submodules.items():
@@ -116,6 +119,7 @@ def upload_age_key_from_nix(
flake_name: FlakeName,
machine_name: str,
) -> None:
log.debug("Uploading secrets for machine %s and flake %s", machine_name, flake_name)
secret_name = f"{machine_name}-age.key"
if not has_secret(
flake_name, secret_name

View File

@@ -1,6 +1,6 @@
import argparse
from ..flakes.types import FlakeName
from ..types import FlakeName
from . import secrets
from .folders import list_objects, remove_object, sops_users_folder
from .sops import read_key, write_key
@@ -131,6 +131,11 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
add_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
add_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_secret_parser.set_defaults(func=add_secret_command)
remove_secret_parser = subparser.add_parser(
@@ -142,4 +147,9 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
remove_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
remove_secret_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
remove_secret_parser.set_defaults(func=remove_secret_command)

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Iterator, Optional, Type, TypeVar
from uuid import UUID, uuid4
from .custom_logger import ThreadFormatter, get_caller
from .errors import ClanError
@@ -38,7 +39,8 @@ class Command:
cwd: Optional[Path] = None,
) -> None:
self.running = True
self.log.debug(f"Running command: {shlex.join(cmd)}")
self.log.debug(f"Command: {shlex.join(cmd)}")
self.log.debug(f"Caller: {get_caller()}")
cwd_res = None
if cwd is not None:
@@ -68,10 +70,10 @@ class Command:
try:
for line in fd:
if fd == self.p.stderr:
print(f"[{cmd[0]}] stderr: {line}")
self.log.debug(f"[{cmd[0]}] stderr: {line}")
self.stderr.append(line)
else:
print(f"[{cmd[0]}] stdout: {line}")
self.log.debug(f"[{cmd[0]}] stdout: {line}")
self.stdout.append(line)
self._output.put(line)
except BlockingIOError:
@@ -80,8 +82,6 @@ class Command:
if self.p.returncode != 0:
raise ClanError(f"Failed to run command: {shlex.join(cmd)}")
self.log.debug("Successfully ran command")
class TaskStatus(str, Enum):
NOTSTARTED = "NOTSTARTED"
@@ -94,7 +94,13 @@ class BaseTask:
def __init__(self, uuid: UUID, num_cmds: int) -> None:
# constructor
self.uuid: UUID = uuid
self.log = logging.getLogger(__name__)
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
handler.setFormatter(ThreadFormatter())
logger = logging.getLogger(__name__)
logger.addHandler(handler)
self.log = logger
self.log = logger
self.procs: list[Command] = []
self.status = TaskStatus.NOTSTARTED
self.logs_lock = threading.Lock()
@@ -108,6 +114,10 @@ class BaseTask:
self.status = TaskStatus.RUNNING
try:
self.run()
# TODO: We need to check, if too many commands have been initialized,
# but not run. This would deadlock the log_lines() function.
# Idea: Run next(cmds) and check if it raises StopIteration if not,
# we have too many commands
except Exception as e:
# FIXME: fix exception handling here
traceback.print_exception(*sys.exc_info())

View File

@@ -0,0 +1,23 @@
import logging
from pathlib import Path
from typing import NewType
log = logging.getLogger(__name__)
FlakeName = NewType("FlakeName", str)
def validate_path(base_dir: Path, value: Path) -> Path:
user_path = (base_dir / value).resolve()
# Check if the path is within the data directory
if not str(user_path).startswith(str(base_dir)):
if not str(user_path).startswith("/tmp/pytest"):
raise ValueError(
f"Destination out of bounds. Expected {user_path} to start with {base_dir}"
)
else:
log.warning(
f"Detected pytest tmpdir. Skipping path validation for {user_path}"
)
return user_path

View File

@@ -4,20 +4,22 @@ import json
import os
import shlex
import sys
import tempfile
from pathlib import Path
from typing import Iterator
from typing import Iterator, Dict
from uuid import UUID
from ..dirs import specific_flake_dir
from ..nix import nix_build, nix_config, nix_shell
from ..dirs import clan_flakes_dir, specific_flake_dir
from ..nix import nix_build, nix_config, nix_eval, nix_shell
from ..task_manager import BaseTask, Command, create_task
from ..types import validate_path
from .inspect import VmConfig, inspect_vm
from ..errors import ClanError
from ..debug import repro_env_break
class BuildVmTask(BaseTask):
def __init__(self, uuid: UUID, vm: VmConfig) -> None:
super().__init__(uuid, num_cmds=6)
super().__init__(uuid, num_cmds=7)
self.vm = vm
def get_vm_create_info(self, cmds: Iterator[Command]) -> dict:
@@ -34,11 +36,18 @@ class BuildVmTask(BaseTask):
]
)
)
vm_json = "".join(cmd.stdout)
vm_json = "".join(cmd.stdout).strip()
self.log.debug(f"VM JSON path: {vm_json}")
with open(vm_json.strip()) as f:
with open(vm_json) as f:
return json.load(f)
def get_clan_name(self, cmds: Iterator[Command]) -> str:
clan_dir = self.vm.flake_url
cmd = next(cmds)
cmd.run(nix_eval([f"{clan_dir}#clanInternals.clanName"]))
clan_name = cmd.stdout[0].strip().strip('"')
return clan_name
def run(self) -> None:
cmds = self.commands()
@@ -47,99 +56,106 @@ class BuildVmTask(BaseTask):
# TODO: We should get this from the vm argument
vm_config = self.get_vm_create_info(cmds)
clan_name = self.get_clan_name(cmds)
with tempfile.TemporaryDirectory() as tmpdir_:
tmpdir = Path(tmpdir_)
xchg_dir = tmpdir / "xchg"
xchg_dir.mkdir()
secrets_dir = tmpdir / "secrets"
secrets_dir.mkdir()
disk_img = f"{tmpdir_}/disk.img"
self.log.debug(f"Building VM for clan name: {clan_name}")
env = os.environ.copy()
env["CLAN_DIR"] = str(self.vm.flake_url)
env["PYTHONPATH"] = str(
":".join(sys.path)
) # TODO do this in the clanCore module
env["SECRETS_DIR"] = str(secrets_dir)
flake_dir = clan_flakes_dir() / clan_name
validate_path(clan_flakes_dir(), flake_dir)
flake_dir.mkdir(exist_ok=True)
cmd = next(cmds)
if Path(self.vm.flake_url).is_dir():
cmd.run(
[vm_config["generateSecrets"]],
env=env,
)
else:
cmd.run(["echo", "won't generate secrets for non local clan"])
xchg_dir = flake_dir / "xchg"
xchg_dir.mkdir()
secrets_dir = flake_dir / "secrets"
secrets_dir.mkdir()
disk_img = f"{flake_dir}/disk.img"
cmd = next(cmds)
env = os.environ.copy()
env["CLAN_DIR"] = str(self.vm.flake_url)
env["PYTHONPATH"] = str(
":".join(sys.path)
) # TODO do this in the clanCore module
env["SECRETS_DIR"] = str(secrets_dir)
cmd = next(cmds)
repro_env_break(work_dir=flake_dir, env=env, cmd=[vm_config["generateSecrets"], clan_name])
if Path(self.vm.flake_url).is_dir():
cmd.run(
[vm_config["uploadSecrets"]],
[vm_config["generateSecrets"], clan_name],
env=env,
)
else:
self.log.warning("won't generate secrets for non local clan")
cmd = next(cmds)
cmd.run(
nix_shell(
["qemu"],
[
"qemu-img",
"create",
"-f",
"raw",
disk_img,
"1024M",
],
)
cmd = next(cmds)
cmd.run(
[vm_config["uploadSecrets"]],
env=env,
)
cmd = next(cmds)
cmd.run(
nix_shell(
["qemu"],
[
"qemu-img",
"create",
"-f",
"raw",
disk_img,
"1024M",
],
)
)
cmd = next(cmds)
cmd.run(
nix_shell(
["e2fsprogs"],
[
"mkfs.ext4",
"-L",
"nixos",
disk_img,
],
)
cmd = next(cmds)
cmd.run(
nix_shell(
["e2fsprogs"],
[
"mkfs.ext4",
"-L",
"nixos",
disk_img,
],
)
)
cmd = next(cmds)
cmdline = [
(Path(vm_config["toplevel"]) / "kernel-params").read_text(),
f'init={vm_config["toplevel"]}/init',
f'regInfo={vm_config["regInfo"]}/registration',
"console=ttyS0,115200n8",
"console=tty0",
]
qemu_command = [
# fmt: off
"qemu-kvm",
"-name", machine,
"-m", f'{vm_config["memorySize"]}M',
"-smp", str(vm_config["cores"]),
"-device", "virtio-rng-pci",
"-net", "nic,netdev=user.0,model=virtio", "-netdev", "user,id=user.0",
"-virtfs", "local,path=/nix/store,security_model=none,mount_tag=nix-store",
"-virtfs", f"local,path={xchg_dir},security_model=none,mount_tag=shared",
"-virtfs", f"local,path={xchg_dir},security_model=none,mount_tag=xchg",
"-virtfs", f"local,path={secrets_dir},security_model=none,mount_tag=secrets",
"-drive", f'cache=writeback,file={disk_img},format=raw,id=drive1,if=none,index=1,werror=report',
"-device", "virtio-blk-pci,bootindex=1,drive=drive1,serial=root",
"-device", "virtio-keyboard",
"-usb",
"-device", "usb-tablet,bus=usb-bus.0",
"-kernel", f'{vm_config["toplevel"]}/kernel',
"-initrd", vm_config["initrd"],
"-append", " ".join(cmdline),
# fmt: on
]
if not self.vm.graphics:
qemu_command.append("-nographic")
print("$ " + shlex.join(qemu_command))
cmd.run(nix_shell(["qemu"], qemu_command))
cmd = next(cmds)
cmdline = [
(Path(vm_config["toplevel"]) / "kernel-params").read_text(),
f'init={vm_config["toplevel"]}/init',
f'regInfo={vm_config["regInfo"]}/registration',
"console=ttyS0,115200n8",
"console=tty0",
]
qemu_command = [
# fmt: off
"qemu-kvm",
"-name", machine,
"-m", f'{vm_config["memorySize"]}M',
"-smp", str(vm_config["cores"]),
"-device", "virtio-rng-pci",
"-net", "nic,netdev=user.0,model=virtio", "-netdev", "user,id=user.0",
"-virtfs", "local,path=/nix/store,security_model=none,mount_tag=nix-store",
"-virtfs", f"local,path={xchg_dir},security_model=none,mount_tag=shared",
"-virtfs", f"local,path={xchg_dir},security_model=none,mount_tag=xchg",
"-virtfs", f"local,path={secrets_dir},security_model=none,mount_tag=secrets",
"-drive", f'cache=writeback,file={disk_img},format=raw,id=drive1,if=none,index=1,werror=report',
"-device", "virtio-blk-pci,bootindex=1,drive=drive1,serial=root",
"-device", "virtio-keyboard",
"-usb",
"-device", "usb-tablet,bus=usb-bus.0",
"-kernel", f'{vm_config["toplevel"]}/kernel',
"-initrd", vm_config["initrd"],
"-append", " ".join(cmdline),
# fmt: on
]
if not self.vm.graphics:
qemu_command.append("-nographic")
print("$ " + shlex.join(qemu_command))
cmd.run(nix_shell(["qemu"], qemu_command))
def create_vm(vm: VmConfig) -> BuildVmTask:

View File

@@ -6,25 +6,11 @@ from pydantic import AnyUrl, BaseModel, validator
from ..dirs import clan_data_dir, clan_flakes_dir
from ..flakes.create import DEFAULT_URL
from ..types import validate_path
log = logging.getLogger(__name__)
def validate_path(base_dir: Path, value: Path) -> Path:
user_path = (base_dir / value).resolve()
# Check if the path is within the data directory
if not str(user_path).startswith(str(base_dir)):
if not str(user_path).startswith("/tmp/pytest"):
raise ValueError(
f"Destination out of bounds. Expected {user_path} to start with {base_dir}"
)
else:
log.warning(
f"Detected pytest tmpdir. Skipping path validation for {user_path}"
)
return user_path
class ClanDataPath(BaseModel):
dest: Path

View File

@@ -9,9 +9,9 @@ from ...config.machine import (
schema_for_machine,
set_config_for_machine,
)
from ...flakes.types import FlakeName
from ...machines.create import create_machine as _create_machine
from ...machines.list import list_machines as _list_machines
from ...types import FlakeName
from ..api_outputs import (
ConfigResponse,
Machine,

View File

@@ -8,9 +8,11 @@
, openssh
, pytest
, pytest-cov
, pytest-xdist
, pytest-subprocess
, pytest-parallel
, pytest-timeout
, remote-pdb
, ipdb
, python3
, runCommand
, setuptools
@@ -45,8 +47,10 @@ let
pytest
pytest-cov
pytest-subprocess
pytest-parallel
pytest-xdist
pytest-timeout
remote-pdb
ipdb
openssh
git
gnupg
@@ -80,9 +84,7 @@ let
source = runCommand "clan-cli-source" { } ''
cp -r ${./.} $out
chmod -R +w $out
rm $out/clan_cli/config/jsonschema
ln -s ${nixpkgs'} $out/clan_cli/nixpkgs
cp -r ${../../lib/jsonschema} $out/clan_cli/config/jsonschema
ln -s ${ui-assets} $out/clan_cli/webui/assets
'';
nixpkgs' = runCommand "nixpkgs" { nativeBuildInputs = [ nix ]; } ''

View File

@@ -14,9 +14,14 @@ exclude = ["clan_cli.nixpkgs*"]
[tool.setuptools.package-data]
clan_cli = [ "config/jsonschema/*", "webui/assets/**/*"]
[tool.pytest.ini_options]
testpaths = "tests"
faulthandler_timeout = 60
addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail --workers auto --durations 5"
log_level = "DEBUG"
log_format = "%(levelname)s: %(message)s"
addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail --durations 5 --color=yes --maxfail=1 --new-first -nauto" # Add --pdb for debugging
norecursedirs = "tests/helpers"
markers = [ "impure" ]

View File

@@ -22,38 +22,41 @@ mkShell {
];
shellHook = ''
tmp_path=$(realpath ./.direnv)
tmp_path=$(realpath ./.direnv)
repo_root=$(realpath .)
mkdir -p "$tmp_path/python/${pythonWithDeps.sitePackages}"
repo_root=$(realpath .)
mkdir -p "$tmp_path/python/${pythonWithDeps.sitePackages}"
# Install the package in editable mode
# This allows executing `clan` from within the dev-shell using the current
# version of the code and its dependencies.
${pythonWithDeps.interpreter} -m pip install \
--quiet \
--disable-pip-version-check \
--no-index \
--no-build-isolation \
--prefix "$tmp_path/python" \
--editable $repo_root
# Install the package in editable mode
# This allows executing `clan` from within the dev-shell using the current
# version of the code and its dependencies.
${pythonWithDeps.interpreter} -m pip install \
--quiet \
--disable-pip-version-check \
--no-index \
--no-build-isolation \
--prefix "$tmp_path/python" \
--editable $repo_root
rm -f clan_cli/nixpkgs clan_cli/webui/assets
ln -sf ${clan-cli.nixpkgs} clan_cli/nixpkgs
ln -sf ${ui-assets} clan_cli/webui/assets
rm -f clan_cli/nixpkgs clan_cli/webui/assets
ln -sf ${clan-cli.nixpkgs} clan_cli/nixpkgs
ln -sf ${ui-assets} clan_cli/webui/assets
export PATH="$tmp_path/python/bin:${checkScript}/bin:$PATH"
export PYTHONPATH="$repo_root:$tmp_path/python/${pythonWithDeps.sitePackages}:"
export PATH="$tmp_path/python/bin:${checkScript}/bin:$PATH"
export PYTHONPATH="$repo_root:$tmp_path/python/${pythonWithDeps.sitePackages}:"
export PYTHONBREAKPOINT=ipdb.set_trace
export XDG_DATA_DIRS="$tmp_path/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export fish_complete_path="$tmp_path/share/fish/vendor_completions.d''${fish_complete_path:+:$fish_complete_path}"
mkdir -p \
$tmp_path/share/fish/vendor_completions.d \
$tmp_path/share/bash-completion/completions \
$tmp_path/share/zsh/site-functions
register-python-argcomplete --shell fish clan > $tmp_path/share/fish/vendor_completions.d/clan.fish
register-python-argcomplete --shell bash clan > $tmp_path/share/bash-completion/completions/clan
export XDG_DATA_DIRS="$tmp_path/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export fish_complete_path="$tmp_path/share/fish/vendor_completions.d''${fish_complete_path:+:$fish_complete_path}"
mkdir -p \
$tmp_path/share/fish/vendor_completions.d \
$tmp_path/share/bash-completion/completions \
$tmp_path/share/zsh/site-functions
register-python-argcomplete --shell fish clan > $tmp_path/share/fish/vendor_completions.d/clan.fish
register-python-argcomplete --shell bash clan > $tmp_path/share/bash-completion/completions/clan
./bin/clan machines create example
./bin/clan flakes create example_clan
./bin/clan machines create example_machine example_clan
'';
}

View File

@@ -1,4 +1,5 @@
import fileinput
import logging
import shutil
import tempfile
from pathlib import Path
@@ -8,7 +9,9 @@ import pytest
from root import CLAN_CORE
from clan_cli.dirs import nixpkgs_source
from clan_cli.flakes.types import FlakeName
from clan_cli.types import FlakeName
log = logging.getLogger(__name__)
# substitutes string sin a file.
@@ -28,73 +31,85 @@ def substitute(
print(line, end="")
class TestFlake(NamedTuple):
class FlakeForTest(NamedTuple):
name: FlakeName
path: Path
def create_flake(
monkeypatch: pytest.MonkeyPatch,
temporary_dir: Path,
flake_name: FlakeName,
clan_core_flake: Path | None = None,
machines: list[str] = [],
remote: bool = False,
) -> Iterator[TestFlake]:
) -> Iterator[FlakeForTest]:
"""
Creates a flake with the given name and machines.
The machine names map to the machines in ./test_machines
"""
template = Path(__file__).parent / flake_name
# copy the template to a new temporary location
with tempfile.TemporaryDirectory() as tmpdir_:
home = Path(tmpdir_)
flake = home / flake_name
shutil.copytree(template, flake)
# lookup the requested machines in ./test_machines and include them
if machines:
(flake / "machines").mkdir(parents=True, exist_ok=True)
for machine_name in machines:
machine_path = Path(__file__).parent / "machines" / machine_name
shutil.copytree(machine_path, flake / "machines" / machine_name)
substitute(flake / "machines" / machine_name / "default.nix", flake)
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
# provided by get_test_flake_toplevel
flake_nix = flake / "flake.nix"
# this is where we would install the sops key to, when updating
substitute(flake_nix, clan_core_flake, flake)
if remote:
with tempfile.TemporaryDirectory() as workdir:
monkeypatch.chdir(workdir)
monkeypatch.setenv("HOME", str(home))
yield TestFlake(flake_name, flake)
else:
monkeypatch.chdir(flake)
home = Path(temporary_dir)
flake = home / ".local/state/clan/flake" / flake_name
shutil.copytree(template, flake)
# lookup the requested machines in ./test_machines and include them
if machines:
(flake / "machines").mkdir(parents=True, exist_ok=True)
for machine_name in machines:
machine_path = Path(__file__).parent / "machines" / machine_name
shutil.copytree(machine_path, flake / "machines" / machine_name)
substitute(flake / "machines" / machine_name / "default.nix", flake)
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
# provided by get_test_flake_toplevel
flake_nix = flake / "flake.nix"
# this is where we would install the sops key to, when updating
substitute(flake_nix, clan_core_flake, flake)
if remote:
with tempfile.TemporaryDirectory() as workdir:
monkeypatch.chdir(workdir)
monkeypatch.setenv("HOME", str(home))
yield TestFlake(flake_name, flake)
yield FlakeForTest(flake_name, flake)
else:
monkeypatch.chdir(flake)
monkeypatch.setenv("HOME", str(home))
yield FlakeForTest(flake_name, flake)
@pytest.fixture
def test_flake(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
yield from create_flake(monkeypatch, FlakeName("test_flake"))
def test_flake(
monkeypatch: pytest.MonkeyPatch, temporary_home: Path
) -> Iterator[FlakeForTest]:
yield from create_flake(monkeypatch, temporary_home, FlakeName("test_flake"))
@pytest.fixture
def test_flake_with_core(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
if not (CLAN_CORE / "flake.nix").exists():
raise Exception(
"clan-core flake not found. This test requires the clan-core flake to be present"
)
yield from create_flake(monkeypatch, FlakeName("test_flake_with_core"), CLAN_CORE)
@pytest.fixture
def test_flake_with_core_and_pass(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[TestFlake]:
def test_flake_with_core(
monkeypatch: pytest.MonkeyPatch, temporary_dir: Path
) -> Iterator[FlakeForTest]:
if not (CLAN_CORE / "flake.nix").exists():
raise Exception(
"clan-core flake not found. This test requires the clan-core flake to be present"
)
yield from create_flake(
monkeypatch, FlakeName("test_flake_with_core_and_pass"), CLAN_CORE
monkeypatch, temporary_dir, FlakeName("test_flake_with_core"), CLAN_CORE
)
@pytest.fixture
def test_flake_with_core_and_pass(
monkeypatch: pytest.MonkeyPatch,
temporary_dir: Path,
) -> Iterator[FlakeForTest]:
if not (CLAN_CORE / "flake.nix").exists():
raise Exception(
"clan-core flake not found. This test requires the clan-core flake to be present"
)
yield from create_flake(
monkeypatch,
temporary_dir,
FlakeName("test_flake_with_core_and_pass"),
CLAN_CORE,
)

View File

@@ -1,6 +1,11 @@
import argparse
import logging
import shlex
from clan_cli import create_parser
from clan_cli.custom_logger import get_caller
log = logging.getLogger(__name__)
class Cli:
@@ -8,6 +13,9 @@ class Cli:
self.parser = create_parser(prog="clan")
def run(self, args: list[str]) -> argparse.Namespace:
cmd = shlex.join(["clan"] + args)
log.debug(f"$ {cmd}")
log.debug(f"Caller {get_caller()}")
parsed = self.parser.parse_args(args)
if hasattr(parsed, "func"):
parsed.func(parsed)

View File

@@ -1,3 +1,4 @@
import logging
import os
import tempfile
from pathlib import Path
@@ -5,14 +6,20 @@ from typing import Iterator
import pytest
log = logging.getLogger(__name__)
@pytest.fixture
def temporary_dir() -> Iterator[Path]:
if os.getenv("TEST_KEEP_TEMPORARY_DIR"):
temp_dir = tempfile.mkdtemp(prefix="pytest-")
path = Path(temp_dir)
def temporary_home(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
env_dir = os.getenv("TEST_TEMPORARY_DIR")
if env_dir is not None:
path = Path(env_dir).resolve()
log.debug("Temp HOME directory: %s", str(path))
monkeypatch.setenv("HOME", str(path))
yield path
print("=========> Keeping temporary directory: ", path)
else:
log.debug("TEST_TEMPORARY_DIR not set, using TemporaryDirectory")
with tempfile.TemporaryDirectory(prefix="pytest-") as dirpath:
monkeypatch.setenv("HOME", str(dirpath))
log.debug("Temp HOME directory: %s", str(dirpath))
yield Path(dirpath)

View File

@@ -9,6 +9,7 @@ from cli import Cli
from clan_cli import config
from clan_cli.config import parsing
from clan_cli.errors import ClanError
from fixtures_flakes import FlakeForTest
example_options = f"{Path(config.__file__).parent}/jsonschema/options.json"
@@ -29,7 +30,7 @@ example_options = f"{Path(config.__file__).parent}/jsonschema/options.json"
def test_set_some_option(
args: list[str],
expected: dict[str, Any],
test_flake: Path,
test_flake: FlakeForTest,
) -> None:
# create temporary file for out_file
with tempfile.NamedTemporaryFile() as out_file:
@@ -46,24 +47,24 @@ def test_set_some_option(
out_file.name,
]
+ args
+ [test_flake.name]
)
json_out = json.loads(open(out_file.name).read())
assert json_out == expected
def test_configure_machine(
test_flake: Path,
temporary_dir: Path,
test_flake: FlakeForTest,
temporary_home: Path,
capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("HOME", str(temporary_dir))
cli = Cli()
cli.run(["config", "-m", "machine1", "clan.jitsi.enable", "true"])
cli.run(["config", "-m", "machine1", "clan.jitsi.enable", "true", test_flake.name])
# clear the output buffer
capsys.readouterr()
# read a option value
cli.run(["config", "-m", "machine1", "clan.jitsi.enable"])
cli.run(["config", "-m", "machine1", "clan.jitsi.enable", test_flake.name])
# read the output
assert capsys.readouterr().out == "true\n"

View File

@@ -6,6 +6,9 @@ import pytest
from api import TestClient
from cli import Cli
from clan_cli.dirs import clan_flakes_dir
from clan_cli.flakes.create import DEFAULT_URL
@pytest.fixture
def cli() -> Cli:
@@ -14,15 +17,16 @@ def cli() -> Cli:
@pytest.mark.impure
def test_create_flake_api(
monkeypatch: pytest.MonkeyPatch, api: TestClient, temporary_dir: Path
monkeypatch: pytest.MonkeyPatch, api: TestClient, temporary_home: Path
) -> None:
flake_dir = temporary_dir / "flake_dir"
flake_dir_str = str(flake_dir.resolve())
monkeypatch.chdir(clan_flakes_dir())
flake_name = "flake_dir"
flake_dir = clan_flakes_dir() / flake_name
response = api.post(
"/api/flake/create",
json=dict(
dest=flake_dir_str,
url="git+https://git.clan.lol/clan/clan-core#new-clan",
dest=str(flake_dir),
url=str(DEFAULT_URL),
),
)
@@ -34,19 +38,21 @@ def test_create_flake_api(
@pytest.mark.impure
def test_create_flake(
monkeypatch: pytest.MonkeyPatch,
temporary_dir: Path,
capsys: pytest.CaptureFixture,
temporary_home: Path,
cli: Cli,
) -> None:
monkeypatch.chdir(temporary_dir)
flake_dir = temporary_dir / "flake_dir"
flake_dir_str = str(flake_dir.resolve())
cli.run(["flake", "create", flake_dir_str])
monkeypatch.chdir(clan_flakes_dir())
flake_name = "flake_dir"
flake_dir = clan_flakes_dir() / flake_name
cli.run(["flakes", "create", flake_name])
assert (flake_dir / ".clan-flake").exists()
monkeypatch.chdir(flake_dir)
cli.run(["machines", "create", "machine1"])
cli.run(["machines", "create", "machine1", flake_name])
capsys.readouterr() # flush cache
cli.run(["machines", "list"])
cli.run(["machines", "list", flake_name])
assert "machine1" in capsys.readouterr().out
flake_show = subprocess.run(
["nix", "flake", "show", "--json"],
@@ -61,6 +67,17 @@ def test_create_flake(
pytest.fail("nixosConfigurations.machine1 not found in flake outputs")
# configure machine1
capsys.readouterr()
cli.run(["config", "--machine", "machine1", "services.openssh.enable"])
cli.run(
["config", "--machine", "machine1", "services.openssh.enable", "", flake_name]
)
capsys.readouterr()
cli.run(["config", "--machine", "machine1", "services.openssh.enable", "true"])
cli.run(
[
"config",
"--machine",
"machine1",
"services.openssh.enable",
"true",
flake_name,
]
)

View File

@@ -9,6 +9,7 @@
let
clan = clan-core.lib.buildClan {
directory = self;
clanName = "test_with_core_clan";
machines = {
vm1 = { lib, ... }: {
clan.networking.deploymentAddress = "__CLAN_DEPLOYMENT_ADDRESS__";

View File

@@ -9,6 +9,7 @@
let
clan = clan-core.lib.buildClan {
directory = self;
clanName = "test_with_core_and_pass_clan";
machines = {
vm1 = { lib, ... }: {
clan.networking.deploymentAddress = "__CLAN_DEPLOYMENT_ADDRESS__";

View File

@@ -9,6 +9,7 @@
let
clan = clan-core.lib.buildClan {
directory = self;
clanName = "core_dynamic_machine_clan";
machines =
let
machineModules = builtins.readDir (self + "/machines");

View File

@@ -1,8 +1,8 @@
from fixtures_flakes import TestFlake
from fixtures_flakes import FlakeForTest
from clan_cli.config import machine
def test_schema_for_machine(test_flake: TestFlake) -> None:
def test_schema_for_machine(test_flake: FlakeForTest) -> None:
schema = machine.schema_for_machine(test_flake.name, "machine1")
assert "properties" in schema

View File

@@ -1,30 +1,33 @@
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Iterator
import pytest
from cli import Cli
from fixtures_flakes import FlakeForTest
from clan_cli.errors import ClanError
if TYPE_CHECKING:
from age_keys import KeyPair
log = logging.getLogger(__name__)
def _test_identities(
what: str,
test_flake: Path,
test_flake: FlakeForTest,
capsys: pytest.CaptureFixture,
age_keys: list["KeyPair"],
) -> None:
cli = Cli()
sops_folder = test_flake / "sops"
sops_folder = test_flake.path / "sops"
cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey])
cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey, test_flake.name])
assert (sops_folder / what / "foo" / "key.json").exists()
with pytest.raises(ClanError):
cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey])
cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey, test_flake.name])
cli.run(
[
@@ -34,73 +37,80 @@ def _test_identities(
"-f",
"foo",
age_keys[0].privkey,
test_flake.name,
]
)
capsys.readouterr() # empty the buffer
cli.run(["secrets", what, "get", "foo"])
cli.run(["secrets", what, "get", "foo", test_flake.name])
out = capsys.readouterr() # empty the buffer
assert age_keys[0].pubkey in out.out
capsys.readouterr() # empty the buffer
cli.run(["secrets", what, "list"])
cli.run(["secrets", what, "list", test_flake.name])
out = capsys.readouterr() # empty the buffer
assert "foo" in out.out
cli.run(["secrets", what, "remove", "foo"])
cli.run(["secrets", what, "remove", "foo", test_flake.name])
assert not (sops_folder / what / "foo" / "key.json").exists()
with pytest.raises(ClanError): # already removed
cli.run(["secrets", what, "remove", "foo"])
cli.run(["secrets", what, "remove", "foo", test_flake.name])
capsys.readouterr()
cli.run(["secrets", what, "list"])
cli.run(["secrets", what, "list", test_flake.name])
out = capsys.readouterr()
assert "foo" not in out.out
def test_users(
test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
test_flake: FlakeForTest, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None:
_test_identities("users", test_flake, capsys, age_keys)
def test_machines(
test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
test_flake: FlakeForTest, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None:
_test_identities("machines", test_flake, capsys, age_keys)
def test_groups(
test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
test_flake: FlakeForTest, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None:
cli = Cli()
capsys.readouterr() # empty the buffer
cli.run(["secrets", "groups", "list"])
cli.run(["secrets", "groups", "list", test_flake.name])
assert capsys.readouterr().out == ""
with pytest.raises(ClanError): # machine does not exist yet
cli.run(["secrets", "groups", "add-machine", "group1", "machine1"])
cli.run(
["secrets", "groups", "add-machine", "group1", "machine1", test_flake.name]
)
with pytest.raises(ClanError): # user does not exist yet
cli.run(["secrets", "groups", "add-user", "groupb1", "user1"])
cli.run(["secrets", "machines", "add", "machine1", age_keys[0].pubkey])
cli.run(["secrets", "groups", "add-machine", "group1", "machine1"])
cli.run(["secrets", "groups", "add-user", "groupb1", "user1", test_flake.name])
cli.run(
["secrets", "machines", "add", "machine1", age_keys[0].pubkey, test_flake.name]
)
cli.run(["secrets", "groups", "add-machine", "group1", "machine1", test_flake.name])
# Should this fail?
cli.run(["secrets", "groups", "add-machine", "group1", "machine1"])
cli.run(["secrets", "groups", "add-machine", "group1", "machine1", test_flake.name])
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
cli.run(["secrets", "groups", "add-user", "group1", "user1"])
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey, test_flake.name])
cli.run(["secrets", "groups", "add-user", "group1", "user1", test_flake.name])
capsys.readouterr() # empty the buffer
cli.run(["secrets", "groups", "list"])
cli.run(["secrets", "groups", "list", test_flake.name])
out = capsys.readouterr().out
assert "user1" in out
assert "machine1" in out
cli.run(["secrets", "groups", "remove-user", "group1", "user1"])
cli.run(["secrets", "groups", "remove-machine", "group1", "machine1"])
groups = os.listdir(test_flake / "sops" / "groups")
cli.run(["secrets", "groups", "remove-user", "group1", "user1", test_flake.name])
cli.run(
["secrets", "groups", "remove-machine", "group1", "machine1", test_flake.name]
)
groups = os.listdir(test_flake.path / "sops" / "groups")
assert len(groups) == 0
@@ -117,104 +127,114 @@ def use_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
def test_secrets(
test_flake: Path,
test_flake: FlakeForTest,
capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch,
age_keys: list["KeyPair"],
) -> None:
cli = Cli()
capsys.readouterr() # empty the buffer
cli.run(["secrets", "list"])
cli.run(["secrets", "list", test_flake.name])
assert capsys.readouterr().out == ""
monkeypatch.setenv("SOPS_NIX_SECRET", "foo")
monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake / ".." / "age.key"))
monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake.path / ".." / "age.key"))
cli.run(["secrets", "key", "generate"])
capsys.readouterr() # empty the buffer
cli.run(["secrets", "key", "show"])
key = capsys.readouterr().out
assert key.startswith("age1")
cli.run(["secrets", "users", "add", "testuser", key])
cli.run(["secrets", "users", "add", "testuser", key, test_flake.name])
with pytest.raises(ClanError): # does not exist yet
cli.run(["secrets", "get", "nonexisting"])
cli.run(["secrets", "set", "initialkey"])
cli.run(["secrets", "get", "nonexisting", test_flake.name])
cli.run(["secrets", "set", "initialkey", test_flake.name])
capsys.readouterr()
cli.run(["secrets", "get", "initialkey"])
cli.run(["secrets", "get", "initialkey", test_flake.name])
assert capsys.readouterr().out == "foo"
capsys.readouterr()
cli.run(["secrets", "users", "list"])
cli.run(["secrets", "users", "list", test_flake.name])
users = capsys.readouterr().out.rstrip().split("\n")
assert len(users) == 1, f"users: {users}"
owner = users[0]
monkeypatch.setenv("EDITOR", "cat")
cli.run(["secrets", "set", "--edit", "initialkey"])
cli.run(["secrets", "set", "--edit", "initialkey", test_flake.name])
monkeypatch.delenv("EDITOR")
cli.run(["secrets", "rename", "initialkey", "key"])
cli.run(["secrets", "rename", "initialkey", "key", test_flake.name])
capsys.readouterr() # empty the buffer
cli.run(["secrets", "list"])
cli.run(["secrets", "list", test_flake.name])
assert capsys.readouterr().out == "key\n"
cli.run(["secrets", "machines", "add", "machine1", age_keys[0].pubkey])
cli.run(["secrets", "machines", "add-secret", "machine1", "key"])
cli.run(
["secrets", "machines", "add", "machine1", age_keys[0].pubkey, test_flake.name]
)
cli.run(["secrets", "machines", "add-secret", "machine1", "key", test_flake.name])
capsys.readouterr()
cli.run(["secrets", "machines", "list"])
cli.run(["secrets", "machines", "list", test_flake.name])
assert capsys.readouterr().out == "machine1\n"
with use_key(age_keys[0].privkey, monkeypatch):
capsys.readouterr()
cli.run(["secrets", "get", "key"])
cli.run(["secrets", "get", "key", test_flake.name])
assert capsys.readouterr().out == "foo"
cli.run(["secrets", "machines", "remove-secret", "machine1", "key"])
cli.run(
["secrets", "machines", "remove-secret", "machine1", "key", test_flake.name]
)
cli.run(["secrets", "users", "add", "user1", age_keys[1].pubkey])
cli.run(["secrets", "users", "add-secret", "user1", "key"])
cli.run(["secrets", "users", "add", "user1", age_keys[1].pubkey, test_flake.name])
cli.run(["secrets", "users", "add-secret", "user1", "key", test_flake.name])
capsys.readouterr()
with use_key(age_keys[1].privkey, monkeypatch):
cli.run(["secrets", "get", "key"])
cli.run(["secrets", "get", "key", test_flake.name])
assert capsys.readouterr().out == "foo"
cli.run(["secrets", "users", "remove-secret", "user1", "key"])
cli.run(["secrets", "users", "remove-secret", "user1", "key", test_flake.name])
with pytest.raises(ClanError): # does not exist yet
cli.run(["secrets", "groups", "add-secret", "admin-group", "key"])
cli.run(["secrets", "groups", "add-user", "admin-group", "user1"])
cli.run(["secrets", "groups", "add-user", "admin-group", owner])
cli.run(["secrets", "groups", "add-secret", "admin-group", "key"])
cli.run(
["secrets", "groups", "add-secret", "admin-group", "key", test_flake.name]
)
cli.run(["secrets", "groups", "add-user", "admin-group", "user1", test_flake.name])
cli.run(["secrets", "groups", "add-user", "admin-group", owner, test_flake.name])
cli.run(["secrets", "groups", "add-secret", "admin-group", "key", test_flake.name])
capsys.readouterr() # empty the buffer
cli.run(["secrets", "set", "--group", "admin-group", "key2"])
cli.run(["secrets", "set", "--group", "admin-group", "key2", test_flake.name])
with use_key(age_keys[1].privkey, monkeypatch):
capsys.readouterr()
cli.run(["secrets", "get", "key"])
cli.run(["secrets", "get", "key", test_flake.name])
assert capsys.readouterr().out == "foo"
# extend group will update secrets
cli.run(["secrets", "users", "add", "user2", age_keys[2].pubkey])
cli.run(["secrets", "groups", "add-user", "admin-group", "user2"])
cli.run(["secrets", "users", "add", "user2", age_keys[2].pubkey, test_flake.name])
cli.run(["secrets", "groups", "add-user", "admin-group", "user2", test_flake.name])
with use_key(age_keys[2].privkey, monkeypatch): # user2
capsys.readouterr()
cli.run(["secrets", "get", "key"])
cli.run(["secrets", "get", "key", test_flake.name])
assert capsys.readouterr().out == "foo"
cli.run(["secrets", "groups", "remove-user", "admin-group", "user2"])
cli.run(
["secrets", "groups", "remove-user", "admin-group", "user2", test_flake.name]
)
with pytest.raises(ClanError), use_key(age_keys[2].privkey, monkeypatch):
# user2 is not in the group anymore
capsys.readouterr()
cli.run(["secrets", "get", "key"])
cli.run(["secrets", "get", "key", test_flake.name])
print(capsys.readouterr().out)
cli.run(["secrets", "groups", "remove-secret", "admin-group", "key"])
cli.run(
["secrets", "groups", "remove-secret", "admin-group", "key", test_flake.name]
)
cli.run(["secrets", "remove", "key"])
cli.run(["secrets", "remove", "key2"])
cli.run(["secrets", "remove", "key", test_flake.name])
cli.run(["secrets", "remove", "key2", test_flake.name])
capsys.readouterr() # empty the buffer
cli.run(["secrets", "list"])
cli.run(["secrets", "list", test_flake.name])
assert capsys.readouterr().out == ""

View File

@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
import pytest
from cli import Cli
from fixtures_flakes import TestFlake
from fixtures_flakes import FlakeForTest
from clan_cli.machines.facts import machine_get_fact
from clan_cli.secrets.folders import sops_secrets_folder
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
@pytest.mark.impure
def test_generate_secret(
monkeypatch: pytest.MonkeyPatch,
test_flake_with_core: TestFlake,
test_flake_with_core: FlakeForTest,
age_keys: list["KeyPair"],
) -> None:
monkeypatch.chdir(test_flake_with_core.path)

View File

@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
from cli import Cli
from fixtures_flakes import TestFlake
from fixtures_flakes import FlakeForTest
from clan_cli.machines.facts import machine_get_fact
from clan_cli.nix import nix_shell
@@ -13,7 +13,7 @@ from clan_cli.ssh import HostGroup
@pytest.mark.impure
def test_upload_secret(
monkeypatch: pytest.MonkeyPatch,
test_flake_with_core_and_pass: TestFlake,
test_flake_with_core_and_pass: FlakeForTest,
temporary_dir: Path,
host_group: HostGroup,
) -> None:

View File

@@ -5,20 +5,23 @@ from typing import TYPE_CHECKING, Iterator
import pytest
from api import TestClient
from cli import Cli
from fixtures_flakes import TestFlake, create_flake
from fixtures_flakes import FlakeForTest, create_flake
from httpx import SyncByteStream
from root import CLAN_CORE
from clan_cli.flakes.types import FlakeName
from clan_cli.types import FlakeName
if TYPE_CHECKING:
from age_keys import KeyPair
@pytest.fixture
def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
def flake_with_vm_with_secrets(
monkeypatch: pytest.MonkeyPatch, temporary_home: Path
) -> Iterator[FlakeForTest]:
yield from create_flake(
monkeypatch,
temporary_home,
FlakeName("test_flake_with_core_dynamic_machines"),
CLAN_CORE,
machines=["vm_with_secrets"],
@@ -27,10 +30,11 @@ def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[Test
@pytest.fixture
def remote_flake_with_vm_without_secrets(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[TestFlake]:
monkeypatch: pytest.MonkeyPatch, temporary_home: Path
) -> Iterator[FlakeForTest]:
yield from create_flake(
monkeypatch,
temporary_home,
FlakeName("test_flake_with_core_dynamic_machines"),
CLAN_CORE,
machines=["vm_without_secrets"],
@@ -41,11 +45,12 @@ def remote_flake_with_vm_without_secrets(
@pytest.fixture
def create_user_with_age_key(
monkeypatch: pytest.MonkeyPatch,
test_flake: FlakeForTest,
age_keys: list["KeyPair"],
) -> None:
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
cli = Cli()
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey, test_flake.name])
def generic_create_vm_test(api: TestClient, flake: Path, vm: str) -> None:
@@ -91,10 +96,10 @@ def generic_create_vm_test(api: TestClient, flake: Path, vm: str) -> None:
def test_create_local(
api: TestClient,
monkeypatch: pytest.MonkeyPatch,
flake_with_vm_with_secrets: Path,
flake_with_vm_with_secrets: FlakeForTest,
create_user_with_age_key: None,
) -> None:
generic_create_vm_test(api, flake_with_vm_with_secrets, "vm_with_secrets")
generic_create_vm_test(api, flake_with_vm_with_secrets.path, "vm_with_secrets")
@pytest.mark.skipif(not os.path.exists("/dev/kvm"), reason="Requires KVM")
@@ -102,8 +107,8 @@ def test_create_local(
def test_create_remote(
api: TestClient,
monkeypatch: pytest.MonkeyPatch,
remote_flake_with_vm_without_secrets: Path,
remote_flake_with_vm_without_secrets: FlakeForTest,
) -> None:
generic_create_vm_test(
api, remote_flake_with_vm_without_secrets, "vm_without_secrets"
api, remote_flake_with_vm_without_secrets.path, "vm_without_secrets"
)

View File

@@ -1,5 +1,7 @@
{
"root": true,
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"ignorePatterns": ["**/src/api/*"]
}

View File

@@ -4,7 +4,7 @@ set -xeuo pipefail
# GITEA_TOKEN
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "GITEA_TOKEN is not set"
echo "Go to https://git.clan.lol/user/settings/applications and generate a token"
echo "Go to https://gitea.gchq.icu/user/settings/applications and generate a token"
exit 1
fi
@@ -22,8 +22,10 @@ nix build '.#ui' --out-link "$tmpdir/result"
tar --transform 's,^\.,assets,' -czvf "$tmpdir/assets.tar.gz" -C "$tmpdir"/result/lib/node_modules/*/out .
NAR_HASH=$(nix-prefetch-url --unpack file://<(cat "$tmpdir/assets.tar.gz"))
url="https://git.clan.lol/api/packages/clan/generic/ui/$NAR_HASH/assets.tar.gz"
owner=Luis
package_name=consulting-website
package_version=$NAR_HASH
url="https://gitea.gchq.icu/api/packages/$owner/generic/$package_name/$package_version/assets.tar.gz"
set +x
curl --upload-file "$tmpdir/assets.tar.gz" -X PUT "$url?token=$GITEA_TOKEN"
set -x

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,5 +0,0 @@
import JoinPrequel from "@/views/joinPrequel";
export default function Page() {
return <JoinPrequel />;
}

View File

@@ -3,11 +3,10 @@ import { Sidebar } from "@/components/sidebar";
import { tw } from "@/utils/tailwind";
import MenuIcon from "@mui/icons-material/Menu";
import {
Button,
CssBaseline,
IconButton,
ThemeProvider,
useMediaQuery,
useMediaQuery
} from "@mui/material";
import { StyledEngineProvider } from "@mui/material/styles";
import axios from "axios";
@@ -62,9 +61,7 @@ export default function RootLayout({
<AppContext.Consumer>
{(appState) => {
const showSidebarDerived = Boolean(
showSidebar &&
!appState.isLoading &&
appState.data.isJoined,
showSidebar && !appState.isLoading,
);
return (
<>
@@ -86,9 +83,7 @@ export default function RootLayout({
hidden={true}
onClick={() => setShowSidebar((c) => !c)}
>
{!showSidebar && appState.data.isJoined && (
<MenuIcon />
)}
{!showSidebar && <MenuIcon />}
</IconButton>
</div>
<div className="col-span-1 block w-full bg-fixed text-center font-semibold dark:invert lg:hidden">
@@ -105,21 +100,7 @@ export default function RootLayout({
<div className="px-1">
<div className="relative flex h-full flex-1 flex-col">
<main>
<Button
fullWidth
onClick={() => {
appState.setAppState((s) => ({
...s,
isJoined: !s.isJoined,
}));
}}
>
Toggle Joined
</Button>
{children}
</main>
<main>{children}</main>
</div>
</div>
</div>

View File

@@ -1,7 +0,0 @@
"use client";
import { CreateMachineForm } from "@/components/createMachineForm";
export default function CreateMachine() {
return <CreateMachineForm />;
}

View File

@@ -1,10 +0,0 @@
interface DeviceEditProps {
params: { name: string };
}
export default function EditDevice(props: DeviceEditProps) {
const {
params: { name },
} = props;
return <div>{name}</div>;
}

View File

@@ -1,5 +0,0 @@
import { MachineContextProvider } from "@/components/hooks/useMachines";
export default function Layout({ children }: { children: React.ReactNode }) {
return <MachineContextProvider>{children}</MachineContextProvider>;
}

View File

@@ -1,12 +0,0 @@
"use client";
import { NodeTable } from "@/components/table";
import { StrictMode } from "react";
export default function Page() {
return (
<StrictMode>
<NodeTable />
</StrictMode>
);
}

View File

@@ -1,41 +1,10 @@
"use client";
import { NetworkOverview } from "@/components/dashboard/NetworkOverview";
import { RecentActivity } from "@/components/dashboard/activity";
import { AppOverview } from "@/components/dashboard/appOverview";
import { Notifications } from "@/components/dashboard/notifications";
import { QuickActions } from "@/components/dashboard/quickActions";
import { TaskQueue } from "@/components/dashboard/taskQueue";
import { useAppState } from "@/components/hooks/useAppContext";
import { LoadingOverlay } from "@/components/join/loadingOverlay";
import JoinPrequel from "@/views/joinPrequel";
// interface DashboardCardProps {
// children?: React.ReactNode;
// rowSpan?: number;
// sx?: string;
// }
// const DashboardCard = (props: DashboardCardProps) => {
// const { children, rowSpan, sx = "" } = props;
// return (
// // <div className={tw`col-span-full row-span-${rowSpan} 2xl:col-span-1 ${sx}`}>
// <div className={tw`row-span-2`}>
// {children}
// </div>
// );
// };
// interface DashboardPanelProps {
// children?: React.ReactNode;
// }
// const DashboardPanel = (props: DashboardPanelProps) => {
// const { children } = props;
// return (
// <div className="col-span-full row-span-1 2xl:col-span-2">{children}</div>
// );
// };
export default function Dashboard() {
const { data, isLoading } = useAppState();
const { isLoading } = useAppState();
if (isLoading) {
return (
<div className="grid h-full place-items-center">
@@ -48,26 +17,13 @@ export default function Dashboard() {
</div>
</div>
);
}
if (!data.isJoined) {
return <JoinPrequel />;
}
if (data.isJoined) {
} else {
return (
<div className="flex w-full">
<div className="grid w-full grid-flow-row grid-cols-3 gap-4">
<div className="row-span-2">
<NetworkOverview />
</div>
<div className="col-span-2">
<AppOverview />
</div>
<div className="row-span-2">
<RecentActivity />
</div>
<QuickActions />
<Notifications />
<TaskQueue />
</div>
</div>
);

View File

@@ -1,174 +0,0 @@
"use client";
import {
Attachment,
ChevronLeft,
Delete,
Edit,
Group,
Key,
Settings,
SettingsEthernet,
} from "@mui/icons-material";
import {
Avatar,
Button,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { useState } from "react";
// import { useListMachines } from "@/api/default/default";
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
function getTemplate(params: { id: string }) {
// const res = await fetch(`https://.../posts/${params.id}`);
return {
short: `My Template ${params.id}`,
};
}
interface TemplateDetailProps {
params: { id: string };
}
export default function TemplateDetail({ params }: TemplateDetailProps) {
// const { data, isLoading } = useListMachines();
const details = getTemplate(params);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<div className="flex w-full flex-col items-center justify-center">
<div className="w-full">
<Button
color="secondary"
LinkComponent={"a"}
href="/templates"
startIcon={<ChevronLeft />}
>
Back
</Button>
</div>
<div className="h-full w-full border border-solid border-neutral-90 bg-neutral-98 shadow-sm shadow-neutral-60 dark:bg-paper-dark">
<div className="flex w-full flex-col items-center justify-center xl:p-2">
<Avatar className="m-1 h-20 w-20 bg-purple-40">
<Typography variant="h5">N</Typography>
</Avatar>
<Typography variant="h6" className="text-purple-40">
{details.short}
</Typography>
<div className="w-full">
<List
className="xl:px-4"
sx={{
".MuiListSubheader-root": {
px: 0,
},
".MuiListItem-root": {
px: 0,
},
}}
>
<ListSubheader>
<Typography variant="caption">Details</Typography>
</ListSubheader>
<ListItem>
<ListItemAvatar>
<SettingsEthernet />
</ListItemAvatar>
<ListItemText primary="network" secondary="10.9.20.2" />
</ListItem>
<ListItem>
<ListItemAvatar>
<Key />
</ListItemAvatar>
<ListItemText primary="secrets" secondary={"< ...hidden >"} />
</ListItem>
<ListItem>
<ListItemAvatar>
<Group />
</ListItemAvatar>
<ListItemText primary="clans" secondary={"Boss clan.lol"} />
</ListItem>
<ListItem>
<ListItemAvatar>
<Attachment />
</ListItemAvatar>
<ListItemText
primary="Image"
secondary={"/nix/store/12789-image-clan-lol"}
/>
<ListItemSecondaryAction>
<IconButton onClick={handleClick}>
<Settings />
</IconButton>
<Menu
MenuListProps={{
className: "m-2",
}}
id="image-menu"
aria-labelledby="image-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem>View</MenuItem>
<MenuItem>Rebuild</MenuItem>
<MenuItem>Delete</MenuItem>
</Menu>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Group />
</ListItemAvatar>
<ListItemText
primary="nodes"
secondary={"Dad's PC; Mum; Olaf; ... 3 more"}
/>
</ListItem>
</List>
</div>
</div>
<div className="mt-2 flex w-full justify-evenly">
<Button
variant="text"
className="w-full text-black dark:text-white"
startIcon={<Edit />}
>
Edit
</Button>
<Button className="w-full text-red" startIcon={<Delete />}>
Delete
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { ChevronRight } from "@mui/icons-material";
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
Typography,
} from "@mui/material";
const templates = [
{
id: "1",
name: "Office Preset",
date: "12 May 2050",
},
{
id: "2",
name: "Work",
date: "30 Feb 2020",
},
{
id: "3",
name: "Family",
date: "1 Okt 2022",
},
{
id: "4",
name: "Standard",
date: "24 Jul 2021",
},
];
export default function ImageOverview() {
return (
<div className="flex flex-col items-center justify-center">
<Typography variant="h4">Templates</Typography>
<List className="w-full gap-y-4">
{templates.map(({ id, name, date }, idx, all) => (
<>
<ListItem key={id}>
<ListItemButton LinkComponent={"a"} href={`/templates/${id}`}>
<ListItemAvatar>
<Avatar className="bg-purple-40">{name.slice(0, 1)}</Avatar>
</ListItemAvatar>
<ListItemText primary={name} secondary={date} />
<ListItemSecondaryAction>
<ChevronRight />
</ListItemSecondaryAction>
</ListItemButton>
</ListItem>
{idx < all.length - 1 && <Divider flexItem className="mx-10" />}
</>
))}
</List>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import {
import { useAppState } from "./hooks/useAppContext";
export default function Background() {
const { data, isLoading } = useAppState();
const { isLoading } = useAppState();
return (
<div
@@ -14,7 +14,7 @@ export default function Background() {
"fixed -z-10 h-[100vh] w-[100vw] overflow-hidden opacity-10 blur-md dark:opacity-40"
}
>
{(isLoading || !data.isJoined) && (
{isLoading && (
<>
<Image
className="dark:hidden"

View File

@@ -1,176 +0,0 @@
"use client";
import { useGetMachineSchema } from "@/api/default/default";
import { Check, Error } from "@mui/icons-material";
import {
Box,
Button,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
Paper,
Typography,
} from "@mui/material";
import { IChangeEvent } from "@rjsf/core";
import { Form } from "@rjsf/mui";
import {
ErrorListProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
TranslatableString,
} from "@rjsf/utils";
import validator from "@rjsf/validator-ajv8";
import { JSONSchema7 } from "json-schema";
import { useMemo, useRef } from "react";
import toast from "react-hot-toast";
import { FormStepContentProps } from "./interfaces";
interface PureCustomConfigProps extends FormStepContentProps {
schema: JSONSchema7;
initialValues: any;
}
export function CustomConfig(props: FormStepContentProps) {
const { formHooks } = props;
const { data, isLoading, error } = useGetMachineSchema("mama");
// const { data, isLoading, error } = { data: {data:{schema: {
// title: 'Test form',
// type: 'object',
// properties: {
// name: {
// type: 'string',
// },
// age: {
// type: 'number',
// },
// },
// }}}, isLoading: false, error: undefined }
const schema = useMemo(() => {
if (!isLoading && !error?.message && data?.data) {
return data?.data.schema;
}
return {};
}, [data, isLoading, error]);
const initialValues = useMemo(
() =>
Object.entries(schema?.properties || {}).reduce((acc, [key, value]) => {
/*@ts-ignore*/
const init: any = value?.default;
if (init) {
return {
...acc,
[key]: init,
};
}
return acc;
}, {}),
[schema],
);
return isLoading ? (
<LinearProgress variant="indeterminate" />
) : error?.message ? (
<div>{error?.message}</div>
) : (
<PureCustomConfig
formHooks={formHooks}
initialValues={initialValues}
schema={schema}
/>
);
}
function ErrorList<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>({ errors, registry }: ErrorListProps<T, S, F>) {
const { translateString } = registry;
return (
<Paper elevation={0}>
<Box mb={2} p={2}>
<Typography variant="h6">
{translateString(TranslatableString.ErrorsLabel)}
</Typography>
<List dense={true}>
{errors.map((error, i: number) => {
return (
<ListItem key={i}>
<ListItemIcon>
<Error color="error" />
</ListItemIcon>
<ListItemText primary={error.stack} />
</ListItem>
);
})}
</List>
</Box>
</Paper>
);
}
function PureCustomConfig(props: PureCustomConfigProps) {
const { schema, formHooks } = props;
const { setValue, watch } = formHooks;
console.log({ schema });
const configData = watch("config") as IChangeEvent<any>;
console.log({ configData });
const setConfig = (data: IChangeEvent<any>) => {
console.log({ data });
setValue("config", data);
};
const formRef = useRef<any>();
const validate = () => {
const isValid: boolean = formRef?.current?.validateForm();
console.log({ isValid }, formRef.current);
if (!isValid) {
formHooks.setError("config", {
message: "invalid config",
});
toast.error(
"Configuration is invalid. Please check the highlighted fields for details.",
);
} else {
formHooks.clearErrors("config");
toast.success("Config seems valid");
}
};
return (
<Form
ref={formRef}
onChange={setConfig}
formData={configData.formData}
acceptcharset="utf-8"
schema={schema}
validator={validator}
liveValidate={true}
templates={{
// ObjectFieldTemplate:
ErrorListTemplate: ErrorList,
ButtonTemplates: {
SubmitButton: (props) => (
<div className="flex w-full items-center justify-center">
<Button
onClick={validate}
startIcon={<Check />}
variant="outlined"
color="secondary"
>
Validate
</Button>
</div>
),
},
}}
/>
);
}

View File

@@ -1,155 +0,0 @@
import {
Box,
Button,
MobileStepper,
Step,
StepLabel,
Stepper,
useMediaQuery,
useTheme,
} from "@mui/material";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { CustomConfig } from "./customConfig";
import { CreateMachineForm, FormStep } from "./interfaces";
export function CreateMachineForm() {
const formHooks = useForm<CreateMachineForm>({
defaultValues: {
name: "",
config: {},
},
});
const { handleSubmit, reset } = formHooks;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [activeStep, setActiveStep] = useState<number>(0);
const steps: FormStep[] = [
{
id: "template",
label: "Template",
content: <div></div>,
},
{
id: "modules",
label: "Modules",
content: <div></div>,
},
{
id: "config",
label: "Customize",
content: <CustomConfig formHooks={formHooks} />,
},
{
id: "save",
label: "Save",
content: <div></div>,
},
];
const handleNext = () => {
if (activeStep < steps.length - 1) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
if (activeStep > 0) {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}
};
const handleReset = () => {
setActiveStep(0);
reset();
};
const currentStep = steps.at(activeStep);
async function onSubmit(data: any) {
console.log({ data }, "Aggregated Data; creating machine from");
}
const BackButton = () => (
<Button
color="secondary"
disabled={activeStep === 0}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
);
const NextButton = () => (
<>
{activeStep !== steps.length - 1 && (
<Button
disabled={!formHooks.formState.isValid}
onClick={handleNext}
color="secondary"
>
{activeStep <= steps.length - 1 && "Next"}
</Button>
)}
{activeStep === steps.length - 1 && (
<Button color="secondary" onClick={handleReset}>
Reset
</Button>
)}
</>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ width: "100%" }}>
{isMobile && (
<MobileStepper
activeStep={activeStep}
color="secondary"
backButton={<BackButton />}
nextButton={<NextButton />}
steps={steps.length}
/>
)}
{!isMobile && (
<Stepper activeStep={activeStep} color="secondary">
{steps.map(({ label }, index) => {
const stepProps: { completed?: boolean } = {};
const labelProps: {
optional?: React.ReactNode;
} = {};
return (
<Step
sx={{
".MuiStepIcon-root.Mui-active": {
color: "secondary.main",
},
".MuiStepIcon-root.Mui-completed": {
color: "secondary.main",
},
}}
key={label}
{...stepProps}
>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
)}
{/* <CustomConfig formHooks={formHooks} /> */}
{/* The step Content */}
{currentStep && currentStep.content}
{/* Desktop step controls */}
{!isMobile && (
<Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
<BackButton />
<Box sx={{ flex: "1 1 auto" }} />
<NextButton />
</Box>
)}
</Box>
</form>
);
}

View File

@@ -1,23 +0,0 @@
import { ReactElement } from "react";
import { UseFormReturn } from "react-hook-form";
export type StepId = "template" | "modules" | "config" | "save";
export type CreateMachineForm = {
name: string;
config: any;
};
export type FormHooks = UseFormReturn<CreateMachineForm>;
export type FormStep = {
id: StepId;
label: string;
content: FormStepContent;
};
export interface FormStepContentProps {
formHooks: FormHooks;
}
export type FormStepContent = ReactElement<FormStepContentProps>;

View File

@@ -1,73 +0,0 @@
import { DashboardCard } from "@/components/card";
import { NoDataOverlay } from "@/components/noDataOverlay";
import { status, Status, clanStatus } from "@/data/dashboardData";
import {
Chip,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
} from "@mui/material";
import Link from "next/link";
import React from "react";
const statusColorMap: Record<
Status,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
online: "info",
offline: "error",
pending: "default",
};
const MAX_OTHERS = 5;
export const NetworkOverview = () => {
const { self, other } = clanStatus;
const firstOthers = other.slice(0, MAX_OTHERS);
return (
<DashboardCard title="Clan Overview">
<List>
<ListItem>
<ListItemText primary={self.name} secondary={self.status} />
<ListItemIcon>
<Chip
label={status[self.status]}
color={statusColorMap[self.status]}
/>
</ListItemIcon>
</ListItem>
<Divider flexItem />
{!other.length && (
<div className="my-3 flex h-full w-full justify-center align-middle">
<NoDataOverlay
label={
<ListItemText
primary="No other nodes"
secondary={<Link href="/nodes">Add devices</Link>}
/>
}
/>
</div>
)}
{firstOthers.map((o) => (
<ListItem key={o.id}>
<ListItemText primary={o.name} secondary={o.status} />
<ListItemIcon>
<Chip label={status[o.status]} color={statusColorMap[o.status]} />
</ListItemIcon>
</ListItem>
))}
{other.length > MAX_OTHERS && (
<ListItem>
<ListItemText
secondary={` ${other.length - MAX_OTHERS} more ...`}
/>
</ListItem>
)}
</List>
</DashboardCard>
);
};

View File

@@ -1,91 +0,0 @@
import { DashboardCard } from "@/components/card";
import Image from "next/image";
interface AppCardProps {
name: string;
icon?: string;
}
const AppCard = (props: AppCardProps) => {
const { name, icon } = props;
const iconPath = icon
? `/app-icons/${icon}`
: "app-icons/app-placeholder.svg";
return (
<div
role="button"
className="flex h-40 w-40 cursor-pointer items-center justify-center rounded-3xl p-2
align-middle shadow-md ring-2 ring-inset ring-purple-50
hover:bg-neutral-90 focus:bg-neutral-90 active:bg-neutral-80
dark:hover:bg-neutral-10 dark:focus:bg-neutral-10 dark:active:bg-neutral-20"
>
<div className="flex w-full flex-col justify-center">
<div className="my-1 flex h-[22] w-[22] items-center justify-center self-center overflow-visible p-1 dark:invert">
<Image
src={iconPath}
alt={`${name}-app-icon`}
width={18 * 3}
height={18 * 3}
/>
</div>
<div className="flex w-full justify-center">{name}</div>
</div>
</div>
);
};
const apps = [
{
name: "Firefox",
icon: "firefox.svg",
},
{
name: "Discord",
icon: "discord.svg",
},
{
name: "Docs",
},
{
name: "Dochub",
icon: "dochub.svg",
},
{
name: "Chess",
icon: "chess.svg",
},
{
name: "Games",
icon: "games.svg",
},
{
name: "Mail",
icon: "mail.svg",
},
{
name: "Public transport",
icon: "public-transport.svg",
},
{
name: "Outlook",
icon: "mail.svg",
},
{
name: "Youtube",
icon: "youtube.svg",
},
];
export const AppOverview = () => {
return (
<DashboardCard title="Applications">
<div className="flex h-full w-full justify-center">
<div className="flex h-full w-fit justify-center">
<div className="grid w-full auto-cols-min auto-rows-min grid-cols-2 gap-8 py-8 sm:grid-cols-3 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 ">
{apps.map((app) => (
<AppCard key={app.name} name={app.name} icon={app.icon} />
))}
</div>
</div>
</div>
</DashboardCard>
);
};

View File

@@ -1,68 +0,0 @@
import { DashboardCard } from "@/components/card";
import { notificationData } from "@/data/dashboardData";
import {
Avatar,
List,
ListItem,
ListItemAvatar,
ListItemText,
} from "@mui/material";
import CheckIcon from "@mui/icons-material/Check";
import InfoIcon from "@mui/icons-material/Info";
import PriorityHighIcon from "@mui/icons-material/PriorityHigh";
import CloseIcon from "@mui/icons-material/Close";
const severityMap = {
info: {
icon: <InfoIcon />,
color: "info",
},
success: {
icon: <CheckIcon />,
color: "success",
},
warning: {
icon: <PriorityHighIcon />,
color: "warning",
},
error: {
icon: <CloseIcon />,
color: "error",
},
};
export const Notifications = () => {
return (
<DashboardCard title="Notifications">
<List>
{notificationData.map((n, idx) => (
<ListItem key={idx}>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: `${n.severity}.main`,
}}
>
{severityMap[n.severity].icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={n.msg}
secondary={n.date}
sx={{
width: "100px",
}}
/>
<ListItemText
primary={n.source}
sx={{
width: "100px",
}}
/>
</ListItem>
))}
</List>
</DashboardCard>
);
};

View File

@@ -1,64 +0,0 @@
"use client";
import { DashboardCard } from "@/components/card";
import { Fab, Typography } from "@mui/material";
import { MouseEventHandler, ReactNode } from "react";
import AppsIcon from "@mui/icons-material/Apps";
import DevicesIcon from "@mui/icons-material/Devices";
import LanIcon from "@mui/icons-material/Lan";
type Action = {
id: string;
icon: ReactNode;
label: ReactNode;
eventHandler: MouseEventHandler<HTMLButtonElement>;
};
export const QuickActions = () => {
const actions: Action[] = [
{
id: "network",
icon: <LanIcon sx={{ mr: 1 }} />,
label: "Network",
eventHandler: (event) => {
console.log({ event });
},
},
{
id: "apps",
icon: <AppsIcon sx={{ mr: 1 }} />,
label: "Apps",
eventHandler: (event) => {
console.log({ event });
},
},
{
id: "nodes",
icon: <DevicesIcon sx={{ mr: 1 }} />,
label: "Devices",
eventHandler: (event) => {
console.log({ event });
},
},
];
return (
<DashboardCard title="Quick Actions">
<div className="flex h-full w-full items-center justify-start pb-10 align-bottom">
<div className="flex w-full flex-col flex-wrap justify-evenly gap-2 sm:flex-row">
{actions.map(({ id, icon, label, eventHandler }) => (
<Fab
className="w-fit self-center shadow-none"
color="secondary"
key={id}
onClick={eventHandler}
variant="extended"
>
{icon}
<Typography>{label}</Typography>
</Fab>
))}
</div>
</div>
</DashboardCard>
);
};

View File

@@ -1,56 +0,0 @@
import { DashboardCard } from "@/components/card";
import SyncIcon from "@mui/icons-material/Sync";
import ScheduleIcon from "@mui/icons-material/Schedule";
import DoneIcon from "@mui/icons-material/Done";
import { ReactNode } from "react";
import { Chip } from "@mui/material";
const statusMap = {
running: <SyncIcon className="animate-bounce" />,
done: <DoneIcon />,
planned: <ScheduleIcon />,
};
interface TaskEntryProps {
status: ReactNode;
result: "default" | "error" | "info" | "success" | "warning";
task: string;
details?: string;
}
const TaskEntry = (props: TaskEntryProps) => {
const { result, task, status } = props;
return (
<>
<div className="col-span-1">{status}</div>
<div className="col-span-4">{task}</div>
<div className="col-span-1">
<Chip color={result} label={result} />
</div>
</>
);
};
export const TaskQueue = () => {
return (
<DashboardCard title="Task Queue">
<div className="grid grid-cols-6 gap-2 p-4">
<TaskEntry
result="success"
task="Update DevX"
status={statusMap.done}
/>
<TaskEntry
result="default"
task="Update XYZ"
status={statusMap.running}
/>
<TaskEntry
result="default"
task="Update ABC"
status={statusMap.planned}
/>
</div>
</DashboardCard>
);
};

View File

@@ -1,21 +0,0 @@
import { Chip } from "@mui/material";
interface FlakeBadgeProps {
flakeUrl: string;
flakeAttr: string;
}
export const FlakeBadge = (props: FlakeBadgeProps) => (
<Chip
color="secondary"
label={`${props.flakeUrl}#${props.flakeAttr}`}
sx={{
p: 2,
"&.MuiChip-root": {
maxWidth: "unset",
},
"&.MuiChip-label": {
overflow: "unset",
},
}}
/>
);

View File

@@ -11,36 +11,28 @@ import React, {
import { KeyedMutator } from "swr";
type AppContextType = {
// data: AxiosResponse<{}, any> | undefined;
data: AppState;
isLoading: boolean;
error: AxiosError<any> | undefined;
setAppState: Dispatch<SetStateAction<AppState>>;
mutate: KeyedMutator<AxiosResponse<MachinesResponse, any>>;
swrKey: string | false | Record<any, any>;
};
// const initialState = {
// isLoading: true,
// } as const;
export const AppContext = createContext<AppContextType>({} as AppContextType);
type AppState = {
isJoined?: boolean;
clanName?: string;
};
type AppState = {};
interface AppContextProviderProps {
children: ReactNode;
}
export const WithAppState = (props: AppContextProviderProps) => {
const { children } = props;
const { isLoading, error, mutate, swrKey } = useListMachines();
const [data, setAppState] = useState<AppState>({ isJoined: false });
const isLoading = false;
const error = undefined;
const [data, setAppState] = useState<AppState>({});
return (
<AppContext.Provider
@@ -49,8 +41,6 @@ export const WithAppState = (props: AppContextProviderProps) => {
setAppState,
isLoading,
error,
swrKey,
mutate,
}}
>
{children}

View File

@@ -1,95 +0,0 @@
"use client";
import { useListMachines } from "@/api/default/default";
import { Machine, MachinesResponse } from "@/api/model";
import { AxiosError, AxiosResponse } from "axios";
import React, {
Dispatch,
ReactNode,
SetStateAction,
createContext,
useMemo,
useState,
} from "react";
import { KeyedMutator } from "swr";
type Filter = {
name: keyof Machine;
value: Machine[keyof Machine];
};
type Filters = Filter[];
type MachineContextType =
| {
rawData: AxiosResponse<MachinesResponse, any> | undefined;
data: Machine[];
isLoading: boolean;
error: AxiosError<any> | undefined;
isValidating: boolean;
filters: Filters;
setFilters: Dispatch<SetStateAction<Filters>>;
mutate: KeyedMutator<AxiosResponse<MachinesResponse, any>>;
swrKey: string | false | Record<any, any>;
}
| {
isLoading: true;
data: readonly [];
};
const initialState = {
isLoading: true,
data: [],
} as const;
export const MachineContext = createContext<MachineContextType>(initialState);
interface MachineContextProviderProps {
children: ReactNode;
}
export const MachineContextProvider = (props: MachineContextProviderProps) => {
const { children } = props;
const {
data: rawData,
isLoading,
error,
isValidating,
mutate,
swrKey,
} = useListMachines();
const [filters, setFilters] = useState<Filters>([]);
const data = useMemo(() => {
if (!isLoading && !error && !isValidating && rawData) {
const { machines } = rawData.data;
return machines.filter((m) =>
filters.every((f) => m[f.name] === f.value),
);
}
return [];
}, [isLoading, error, isValidating, rawData, filters]);
return (
<MachineContext.Provider
value={{
rawData,
data,
isLoading,
error,
isValidating,
filters,
setFilters,
swrKey,
mutate,
}}
>
{children}
</MachineContext.Provider>
);
};
export const useMachines = () => React.useContext(MachineContext);

View File

@@ -1,52 +0,0 @@
import { inspectVm } from "@/api/default/default";
import { HTTPValidationError, VmConfig } from "@/api/model";
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
interface UseVmsOptions {
url: string;
attr: string;
}
export const useVms = (options: UseVmsOptions) => {
const { url, attr } = options;
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<VmConfig>();
const [error, setError] = useState<AxiosError<HTTPValidationError>>();
useEffect(() => {
const getVmInfo = async (url: string, attr: string) => {
if (url === "" || !url) {
toast.error("Flake url is missing", { id: "missing.flake.url" });
return undefined;
}
try {
const response = await inspectVm({
flake_attr: attr,
flake_url: url,
});
const {
data: { config },
} = response;
setError(undefined);
return config;
} catch (e) {
const err = e as AxiosError<HTTPValidationError>;
setError(err);
toast(
"Could not find default configuration. Please select a machine preset",
);
return undefined;
} finally {
setIsLoading(false);
}
};
getVmInfo(url, attr).then((c) => setConfig(c));
}, [url, attr]);
return {
error,
isLoading,
config,
};
};

View File

@@ -1,165 +0,0 @@
import {
Button,
InputAdornment,
LinearProgress,
ListSubheader,
MenuItem,
Select,
Switch,
TextField,
} from "@mui/material";
import { Controller, SubmitHandler, UseFormReturn } from "react-hook-form";
import { FlakeBadge } from "../flakeBadge/flakeBadge";
import { createVm, useInspectFlakeAttrs } from "@/api/default/default";
import { VmConfig } from "@/api/model";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useAppState } from "../hooks/useAppContext";
interface VmPropLabelProps {
children: React.ReactNode;
}
const VmPropLabel = (props: VmPropLabelProps) => (
<div className="col-span-4 flex items-center sm:col-span-1">
{props.children}
</div>
);
interface VmPropContentProps {
children: React.ReactNode;
}
const VmPropContent = (props: VmPropContentProps) => (
<div className="col-span-4 sm:col-span-3">{props.children}</div>
);
interface VmDetailsProps {
formHooks: UseFormReturn<VmConfig, any, undefined>;
setVmUuid: Dispatch<SetStateAction<string | null>>;
}
export const ConfigureVM = (props: VmDetailsProps) => {
const { formHooks, setVmUuid } = props;
const { control, handleSubmit, watch, setValue } = formHooks;
const [isStarting, setStarting] = useState(false);
const { setAppState } = useAppState();
const { isLoading, data } = useInspectFlakeAttrs({ url: watch("flake_url") });
useEffect(() => {
if (!isLoading && data?.data) {
setValue("flake_attr", data.data.flake_attrs[0] || "");
}
}, [isLoading, setValue, data]);
const onSubmit: SubmitHandler<VmConfig> = async (data) => {
setStarting(true);
console.log(data);
const response = await createVm(data);
const { uuid } = response?.data || null;
setVmUuid(() => uuid);
setStarting(false);
if (response.statusText === "OK") {
toast.success(("Joined @ " + uuid) as string);
setAppState((s) => ({ ...s, isJoined: true }));
} else {
toast.error("Could not join");
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="grid grid-cols-4 gap-y-10"
>
<div className="col-span-4">
<ListSubheader sx={{ bgcolor: "inherit" }}>General</ListSubheader>
</div>
<VmPropLabel>Flake</VmPropLabel>
<VmPropContent>
<FlakeBadge
flakeAttr={watch("flake_attr")}
flakeUrl={watch("flake_url")}
/>
</VmPropContent>
<VmPropLabel>Machine</VmPropLabel>
<VmPropContent>
{!isLoading && (
<Controller
name="flake_attr"
control={control}
render={({ field }) => (
<Select
{...field}
required
variant="standard"
fullWidth
disabled={isLoading}
>
{!data?.data.flake_attrs.includes("default") && (
<MenuItem value={"default"}>default</MenuItem>
)}
{data?.data.flake_attrs.map((attr) => (
<MenuItem value={attr} key={attr}>
{attr}
</MenuItem>
))}
</Select>
)}
/>
)}
</VmPropContent>
<div className="col-span-4">
<ListSubheader sx={{ bgcolor: "inherit" }}>VM</ListSubheader>
</div>
<VmPropLabel>CPU Cores</VmPropLabel>
<VmPropContent>
<Controller
name="cores"
control={control}
render={({ field }) => <TextField type="number" {...field} />}
/>
</VmPropContent>
<VmPropLabel>Graphics</VmPropLabel>
<VmPropContent>
<Controller
name="graphics"
control={control}
render={({ field }) => (
<Switch {...field} defaultChecked={watch("graphics")} />
)}
/>
</VmPropContent>
<VmPropLabel>Memory Size</VmPropLabel>
<VmPropContent>
<Controller
name="memory_size"
control={control}
render={({ field }) => (
<TextField
type="number"
{...field}
InputProps={{
endAdornment: (
<InputAdornment position="end">MiB</InputAdornment>
),
}}
/>
)}
/>
</VmPropContent>
<div className="col-span-4 grid items-center">
{isStarting && <LinearProgress />}
<Button
autoFocus
type="submit"
disabled={isStarting}
variant="contained"
>
Join Clan
</Button>
</div>
</form>
);
};

View File

@@ -1,63 +0,0 @@
"use client";
import { useState } from "react";
import { LoadingOverlay } from "./loadingOverlay";
import { FlakeBadge } from "../flakeBadge/flakeBadge";
import { Typography, Button } from "@mui/material";
// import { FlakeResponse } from "@/api/model";
import { ConfirmVM } from "./confirmVM";
import { Log } from "./log";
import GppMaybeIcon from "@mui/icons-material/GppMaybe";
import { useInspectFlake } from "@/api/default/default";
interface ConfirmProps {
flakeUrl: string;
flakeAttr: string;
handleBack: () => void;
}
export const Confirm = (props: ConfirmProps) => {
const { flakeUrl, handleBack, flakeAttr } = props;
const [userConfirmed, setUserConfirmed] = useState(false);
const { data, isLoading } = useInspectFlake({
url: flakeUrl,
});
return userConfirmed ? (
<ConfirmVM
url={flakeUrl}
handleBack={handleBack}
defaultFlakeAttr={flakeAttr}
/>
) : (
<div className="mb-2 flex w-full max-w-2xl flex-col items-center justify-self-center pb-2 ">
{isLoading && (
<LoadingOverlay
title={"Loading Flake"}
subtitle={<FlakeBadge flakeUrl={flakeUrl} flakeAttr={flakeAttr} />}
/>
)}
{data && (
<>
<Typography variant="subtitle1">
To join the clan you must trust the Author
</Typography>
<GppMaybeIcon sx={{ height: "10rem", width: "10rem", mb: 5 }} />
<Button
autoFocus
size="large"
color="warning"
variant="contained"
onClick={() => setUserConfirmed(true)}
sx={{ mb: 10 }}
>
Trust Flake Author
</Button>
<Log
title="What's about to be built"
lines={data.data.content.split("\n")}
/>
</>
)}
</div>
);
};

View File

@@ -1,61 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { VmConfig } from "@/api/model";
import { useVms } from "@/components/hooks/useVms";
import { LoadingOverlay } from "./loadingOverlay";
import { useForm } from "react-hook-form";
import { ConfigureVM } from "./configureVM";
import { VmBuildLogs } from "./vmBuildLogs";
interface ConfirmVMProps {
url: string;
handleBack: () => void;
defaultFlakeAttr: string;
}
export function ConfirmVM(props: ConfirmVMProps) {
const { url, defaultFlakeAttr } = props;
const formHooks = useForm<VmConfig>({
defaultValues: {
flake_url: url,
flake_attr: defaultFlakeAttr,
cores: 4,
graphics: true,
memory_size: 2048,
},
});
const [vmUuid, setVmUuid] = useState<string | null>(null);
const { setValue, watch, formState } = formHooks;
const { config, isLoading } = useVms({
url,
attr: watch("flake_attr") || defaultFlakeAttr,
});
useEffect(() => {
if (config) {
setValue("cores", config?.cores);
setValue("memory_size", config?.memory_size);
setValue("graphics", config?.graphics);
}
}, [config, setValue]);
return (
<div className="mb-2 flex w-full max-w-2xl flex-col items-center justify-self-center pb-2">
{!formState.isSubmitted && (
<>
<div className="mb-2 w-full max-w-2xl">
{isLoading && (
<LoadingOverlay title={"Loading VM Configuration"} subtitle="" />
)}
<ConfigureVM formHooks={formHooks} setVmUuid={setVmUuid} />
</div>
</>
)}
{formState.isSubmitted && vmUuid && <VmBuildLogs vmUuid={vmUuid} />}
</div>
);
}

View File

@@ -1,20 +0,0 @@
"use client";
import { Typography } from "@mui/material";
import { ReactNode } from "react";
interface LayoutProps {
children: ReactNode;
}
export const Layout = (props: LayoutProps) => {
return (
<div className="grid h-[70vh] w-full grid-cols-1 justify-center gap-y-4">
<Typography variant="h4" className="w-full text-center">
Join{" "}
<Typography variant="h4" className="font-bold" component={"span"}>
Clan.lol
</Typography>
</Typography>
{props.children}
</div>
);
};

View File

@@ -11,14 +11,9 @@ import Image from "next/image";
import { ReactNode } from "react";
import { tw } from "@/utils/tailwind";
import AppsIcon from "@mui/icons-material/Apps";
import BackupIcon from "@mui/icons-material/Backup";
import DashboardIcon from "@mui/icons-material/Dashboard";
import DesignServicesIcon from "@mui/icons-material/DesignServices";
import DevicesIcon from "@mui/icons-material/Devices";
import LanIcon from "@mui/icons-material/Lan";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import Link from "next/link";
import WysiwygIcon from "@mui/icons-material/Wysiwyg";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
type MenuEntry = {
@@ -32,39 +27,15 @@ type MenuEntry = {
const menuEntries: MenuEntry[] = [
{
icon: <DashboardIcon />,
label: "Dashoard",
icon: <AssignmentIndIcon />,
label: "Freelance",
to: "/",
disabled: false,
},
{
icon: <DevicesIcon />,
label: "Machines",
to: "/machines",
disabled: false,
},
{
icon: <AppsIcon />,
label: "Applications",
to: "/applications",
disabled: true,
},
{
icon: <LanIcon />,
label: "Network",
to: "/network",
disabled: true,
},
{
icon: <DesignServicesIcon />,
label: "Templates",
to: "/templates",
disabled: false,
},
{
icon: <BackupIcon />,
label: "Backups",
to: "/backups",
icon: <WysiwygIcon />,
label: "Blog",
to: "/blog",
disabled: true,
},
];
@@ -138,23 +109,6 @@ export function Sidebar(props: SidebarProps) {
);
})}
</List>
<Divider
flexItem
className="mx-8 my-10 hidden bg-neutral-40 lg:block"
/>
<div className="mx-auto mb-8 hidden w-full max-w-xs rounded-sm px-4 py-6 text-center align-bottom shadow-sm lg:block">
<h3 className="mb-2 w-full font-semibold text-white">
Clan.lol Admin
</h3>
<a
href=""
target="_blank"
rel="nofollow"
className="inline-block w-full rounded-md p-2 text-center text-white hover:text-purple-60/95"
>
Donate
</a>
</div>
</div>
</aside>
);

View File

@@ -1,82 +0,0 @@
"use client";
import React, { useMemo } from "react";
import Box from "@mui/material/Box";
import Grid2 from "@mui/material/Unstable_Grid2";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { PieCards } from "./pieCards";
import { PieData, NodePieChart } from "./nodePieChart";
import { Machine } from "@/api/model/machine";
import { Status } from "@/api/model";
interface EnhancedTableToolbarProps {
tableData: readonly Machine[];
}
export function EnhancedTableToolbar(
props: React.PropsWithChildren<EnhancedTableToolbarProps>,
) {
const { tableData } = props;
const theme = useTheme();
const is_lg = useMediaQuery(theme.breakpoints.down("lg"));
const pieData: PieData[] = useMemo(() => {
const online = tableData.filter(
(row) => row.status === Status.online,
).length;
const offline = tableData.filter(
(row) => row.status === Status.offline,
).length;
const pending = tableData.filter(
(row) => row.status === Status.unknown,
).length;
return [
{ name: "Online", value: online, color: theme.palette.success.main },
{ name: "Offline", value: offline, color: theme.palette.error.main },
{ name: "Pending", value: pending, color: theme.palette.warning.main },
];
}, [tableData, theme]);
return (
<Grid2 container spacing={1}>
{/* Pie Chart Grid */}
<Grid2
key="PieChart"
md={6}
xs={12}
display="flex"
justifyContent="center"
alignItems="center"
>
<Box height={350} width={400}>
<NodePieChart data={pieData} showLabels={is_lg} />
</Box>
</Grid2>
{/* Card Stack Grid */}
<Grid2
key="CardStack"
lg={6}
display="flex"
sx={{ display: { lg: "flex", xs: "none", md: "flex" } }}
>
<PieCards pieData={pieData} />
</Grid2>
{/*Toolbar Grid */}
<Grid2
key="Toolbar"
xs={12}
container
justifyContent="center"
alignItems="center"
sx={{ pl: { sm: 2 }, pr: { xs: 1, sm: 1 }, pt: { xs: 1, sm: 3 } }}
>
{props.children}
</Grid2>
</Grid2>
);
}

View File

@@ -1 +0,0 @@
export { NodeTable } from "./nodeTable";

View File

@@ -1,57 +0,0 @@
import React from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend } from "recharts";
import { useTheme } from "@mui/material/styles";
import { Box } from "@mui/material";
export interface PieData {
name: string;
value: number;
color: string;
}
interface Props {
data: PieData[];
showLabels?: boolean;
}
export function NodePieChart(props: Props) {
const theme = useTheme();
const { data, showLabels } = props;
return (
<Box height={350}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
innerRadius={85}
outerRadius={120}
fill={theme.palette.primary.main}
dataKey="value"
nameKey="name"
label={showLabels}
legendType="square"
cx="50%"
cy="50%"
startAngle={0}
endAngle={360}
paddingAngle={0}
labelLine={true}
hide={false}
minAngle={0}
isAnimationActive={true}
animationBegin={0}
animationDuration={1000}
animationEasing="ease-in"
blendStroke={true}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend verticalAlign="bottom" />
</PieChart>
</ResponsiveContainer>
</Box>
);
}

View File

@@ -1,135 +0,0 @@
"use client";
import * as React from "react";
import Box from "@mui/material/Box";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircleIcon from "@mui/icons-material/Circle";
import Stack from "@mui/material/Stack/Stack";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import Grid2 from "@mui/material/Unstable_Grid2"; // Grid version 2
import { Collapse } from "@mui/material";
import { Machine, Status } from "@/api/model";
function renderStatus(status: Status) {
switch (status) {
case Status.online:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="success" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Online
</Typography>
</Stack>
);
case Status.offline:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="error" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Offline
</Typography>
</Stack>
);
case Status.unknown:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="warning" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Pending
</Typography>
</Stack>
);
}
}
export function NodeRow(props: {
row: Machine;
selected: string | undefined;
setSelected: (a: string | undefined) => void;
}) {
const { row, selected, setSelected } = props;
const [open, setOpen] = React.useState(false);
// Speed optimization. We compare string pointers here instead of the string content.
const isSelected = selected == row.name;
const handleClick = (event: React.MouseEvent<unknown>, name: string) => {
if (isSelected) {
setSelected(undefined);
} else {
setSelected(name);
}
};
return (
<React.Fragment>
{/* Rendered Row */}
<TableRow
hover
role="checkbox"
aria-checked={isSelected}
tabIndex={-1}
key={row.name}
selected={isSelected}
sx={{ cursor: "pointer" }}
>
<TableCell padding="none">
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell
component="th"
scope="row"
onClick={(event) => handleClick(event, row.name)}
>
<Stack>
<Typography component="div" align="left" variant="body1">
{row.name}
</Typography>
</Stack>
</TableCell>
<TableCell
align="right"
onClick={(event) => handleClick(event, row.name)}
>
{renderStatus(row.status)}
</TableCell>
</TableRow>
{/* Row Expansion */}
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1 }}>
<Typography variant="h6" gutterBottom component="div">
Metadata
</Typography>
<Grid2 container spacing={2} paddingLeft={0}>
<Grid2
xs={6}
justifyContent="left"
display="flex"
paddingRight={3}
>
<Box>Hello1</Box>
</Grid2>
<Grid2 xs={6} paddingLeft={6}>
<Box>Hello2</Box>
</Grid2>
</Grid2>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}

View File

@@ -1,96 +0,0 @@
"use client";
import { CircularProgress, Grid, useTheme } from "@mui/material";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import TablePagination from "@mui/material/TablePagination";
import useMediaQuery from "@mui/material/useMediaQuery";
import { ChangeEvent, useMemo, useState } from "react";
import { Machine } from "@/api/model/machine";
import Grid2 from "@mui/material/Unstable_Grid2/Grid2";
import { useMachines } from "../hooks/useMachines";
import { EnhancedTableToolbar } from "./enhancedTableToolbar";
import { NodeTableContainer } from "./nodeTableContainer";
import { SearchBar } from "./searchBar";
import { StickySpeedDial } from "./stickySpeedDial";
export function NodeTable() {
const machines = useMachines();
const theme = useTheme();
const is_xs = useMediaQuery(theme.breakpoints.only("xs"));
const [selected, setSelected] = useState<string | undefined>(undefined);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const [filteredList, setFilteredList] = useState<readonly Machine[]>([]);
const tableData = useMemo(() => {
const tableData = machines.data.map((machine) => {
return { name: machine.name, status: machine.status };
});
setFilteredList(tableData);
return tableData;
}, [machines.data]);
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
if (machines.isLoading) {
return (
<Grid
container
sx={{
h: "100vh",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={80} color="secondary" />
</Grid>
);
}
return (
<Box sx={{ width: "100%" }}>
<Paper sx={{ width: "100%", mb: 2 }}>
<StickySpeedDial selected={selected} />
<EnhancedTableToolbar tableData={tableData}>
<Grid2 xs={12}>
<SearchBar
tableData={tableData}
setFilteredList={setFilteredList}
/>
</Grid2>
</EnhancedTableToolbar>
<NodeTableContainer
tableData={filteredList}
page={page}
rowsPerPage={rowsPerPage}
dense={false}
selected={selected}
setSelected={setSelected}
/>
{/* TODO: This creates the error Warning: Prop `id` did not match. Server: ":RspmmcqH1:" Client: ":R3j6qpj9H1:" */}
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
labelRowsPerPage={is_xs ? "Rows" : "Rows per page:"}
component="div"
count={filteredList.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
);
}

View File

@@ -1,197 +0,0 @@
"use client";
import * as React from "react";
import Box from "@mui/material/Box";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TableSortLabel from "@mui/material/TableSortLabel";
import { visuallyHidden } from "@mui/utils";
import { NodeRow } from "./nodeRow";
import { Machine } from "@/api/model/machine";
interface HeadCell {
disablePadding: boolean;
id: keyof Machine;
label: string;
alignRight: boolean;
}
const headCells: readonly HeadCell[] = [
{
id: "name",
alignRight: false,
disablePadding: false,
label: "DOMAIN NAME",
},
{
id: "status",
alignRight: false,
disablePadding: false,
label: "STATUS",
},
];
function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
if (b[orderBy] < a[orderBy]) {
return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}
export type NodeOrder = "asc" | "desc";
function getComparator<Key extends keyof any>(
order: NodeOrder,
orderBy: Key,
): (
a: { [key in Key]: number | string | boolean },
b: { [key in Key]: number | string | boolean },
) => number {
return order === "desc"
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy);
}
// Since 2020 all major browsers ensure sort stability with Array.prototype.sort().
// stableSort() brings sort stability to non-modern browsers (notably IE11). If you
// only support modern browsers you can replace stableSort(exampleArray, exampleComparator)
// with exampleArray.slice().sort(exampleComparator)
function stableSort<T>(
array: readonly T[],
comparator: (a: T, b: T) => number,
) {
const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0]);
if (order !== 0) {
return order;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}
interface EnhancedTableProps {
onRequestSort: (
event: React.MouseEvent<unknown>,
property: keyof Machine,
) => void;
order: NodeOrder;
orderBy: string;
rowCount: number;
}
function EnhancedTableHead(props: EnhancedTableProps) {
const { order, orderBy, onRequestSort } = props;
const createSortHandler =
(property: keyof Machine) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property);
};
return (
<TableHead>
<TableRow>
<TableCell id="dropdown" colSpan={1} />
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
align={headCell.alignRight ? "right" : "left"}
padding={headCell.disablePadding ? "none" : "normal"}
sortDirection={orderBy === headCell.id ? order : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : "asc"}
onClick={createSortHandler(headCell.id)}
>
{headCell.label}
{orderBy === headCell.id ? (
<Box component="span" sx={visuallyHidden}>
{order === "desc" ? "sorted descending" : "sorted ascending"}
</Box>
) : null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
);
}
interface NodeTableContainerProps {
tableData: readonly Machine[];
page: number;
rowsPerPage: number;
dense: boolean;
selected: string | undefined;
setSelected: React.Dispatch<React.SetStateAction<string | undefined>>;
}
export function NodeTableContainer(props: NodeTableContainerProps) {
const { tableData, page, rowsPerPage, dense, selected, setSelected } = props;
const [order, setOrder] = React.useState<NodeOrder>("asc");
const [orderBy, setOrderBy] = React.useState<keyof Machine>("status");
// Avoid a layout jump when reaching the last page with empty rows.
const emptyRows =
page > 0 ? Math.max(0, (1 + page) * rowsPerPage - tableData.length) : 0;
const handleRequestSort = (
event: React.MouseEvent<unknown>,
property: keyof Machine,
) => {
const isAsc = orderBy === property && order === "asc";
setOrder(isAsc ? "desc" : "asc");
setOrderBy(property);
};
const visibleRows = React.useMemo(
() =>
stableSort(tableData, getComparator(order, orderBy)).slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
),
[order, orderBy, page, rowsPerPage, tableData],
);
return (
<TableContainer>
<Table aria-labelledby="tableTitle" size={dense ? "small" : "medium"}>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={tableData.length}
/>
<TableBody>
{visibleRows.map((row, index) => {
return (
<NodeRow
key={index}
row={row}
selected={selected}
setSelected={setSelected}
/>
);
})}
{emptyRows > 0 && (
<TableRow
style={{
height: (dense ? 33 : 53) * emptyRows,
}}
>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@@ -1,73 +0,0 @@
import { Card, CardContent, Stack, Typography } from "@mui/material";
import hexRgb from "hex-rgb";
import { useMemo } from "react";
interface PieData {
name: string;
value: number;
color: string;
}
interface PieCardsProps {
pieData: PieData[];
}
export function PieCards(props: PieCardsProps) {
const { pieData } = props;
const cardData = useMemo(() => {
return pieData
.filter((pieItem) => pieItem.value > 0)
.concat({
name: "Total",
value: pieData.reduce((a, b) => a + b.value, 0),
color: "#000000",
});
}, [pieData]);
return (
<Stack
sx={{ paddingTop: 6 }}
height={350}
id="cardBox"
display="flex"
flexDirection="column"
justifyContent="flex-start"
flexWrap="wrap"
>
{cardData.map((pieItem) => (
<Card
key={pieItem.name}
sx={{
marginBottom: 2,
marginRight: 2,
width: 110,
height: 110,
backgroundColor: hexRgb(pieItem.color, {
format: "css",
alpha: 0.25,
}),
}}
>
<CardContent>
<Typography
variant="h4"
component="div"
gutterBottom={true}
textAlign="center"
>
{pieItem.value}
</Typography>
<Typography
sx={{ mb: 1.5 }}
color="text.secondary"
textAlign="center"
>
{pieItem.name}
</Typography>
</CardContent>
</Card>
))}
</Stack>
);
}

View File

@@ -1,99 +0,0 @@
"use client";
import { SetStateAction, Dispatch, useState, useEffect, useMemo } from "react";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import { useDebounce } from "../hooks/useDebounce";
import { Autocomplete, InputAdornment, TextField } from "@mui/material";
import { Machine } from "@/api/model/machine";
export interface SearchBarProps {
tableData: readonly Machine[];
setFilteredList: Dispatch<SetStateAction<readonly Machine[]>>;
}
export function SearchBar(props: SearchBarProps) {
let { tableData, setFilteredList } = props;
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce(search, 250);
const [open, setOpen] = useState(false);
// Define a function to handle the Esc key press
function handleEsc(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
setSearch("");
setFilteredList(tableData);
}
// check if the key is Enter
if (event.key === "Enter") {
setOpen(false);
}
}
useEffect(() => {
if (debouncedSearch) {
const filtered: Machine[] = tableData.filter((row) => {
return row.name.toLowerCase().includes(debouncedSearch.toLowerCase());
});
setFilteredList(filtered);
}
}, [debouncedSearch, tableData, setFilteredList]);
const handleInputChange = (event: any, value: string) => {
if (value === "") {
setFilteredList(tableData);
}
setSearch(value);
};
const suggestions = useMemo(
() => tableData.map((row) => row.name),
[tableData],
);
return (
<Autocomplete
freeSolo
autoComplete
options={suggestions}
renderOption={(props: any, option: any) => {
return (
<li {...props} key={option}>
{option}
</li>
);
}}
onKeyDown={handleEsc}
onInputChange={handleInputChange}
value={search}
open={open}
onOpen={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label="Search"
variant="outlined"
autoComplete="nickname"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<IconButton>
<SearchIcon />
</IconButton>
</InputAdornment>
),
}}
></TextField>
)}
/>
);
}

View File

@@ -1,85 +0,0 @@
"use client";
import * as React from "react";
import Box from "@mui/material/Box";
import DeleteIcon from "@mui/icons-material/Delete";
import SpeedDial, { CloseReason, OpenReason } from "@mui/material/SpeedDial";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
import SpeedDialAction from "@mui/material/SpeedDialAction";
import EditIcon from "@mui/icons-material/ModeEdit";
import AddIcon from "@mui/icons-material/Add";
import Link from "next/link";
export function StickySpeedDial(props: { selected: string | undefined }) {
const { selected } = props;
const [open, setOpen] = React.useState(false);
function handleClose(event: any, reason: CloseReason) {
if (reason === "toggle" || reason === "escapeKeyDown") {
setOpen(false);
}
}
function handleOpen(event: any, reason: OpenReason) {
if (reason === "toggle") {
setOpen(true);
}
}
const isSomethingSelected = selected != undefined;
function editDial() {
if (isSomethingSelected) {
return (
<Link href={`/machines/edit/${selected}`} style={{ marginTop: 7.5 }}>
<EditIcon color="action" />
</Link>
);
} else {
return <EditIcon color="disabled" />;
}
}
return (
<Box
sx={{
transform: "translateZ(0px)",
flexGrow: 1,
position: "fixed",
right: 20,
top: 15,
margin: 0,
zIndex: 9000,
}}
>
<SpeedDial
color="secondary"
ariaLabel="SpeedDial basic example"
icon={<SpeedDialIcon />}
direction="down"
onClose={handleClose}
onOpen={handleOpen}
open={open}
>
<SpeedDialAction
key="Add"
icon={
<Link href="/machines/add" style={{ marginTop: 7.5 }}>
<AddIcon color="action" />
</Link>
}
tooltipTitle="Add"
/>
<SpeedDialAction
key="Delete"
icon={
<DeleteIcon color={isSomethingSelected ? "action" : "disabled"} />
}
tooltipTitle="Delete"
/>
<SpeedDialAction key="Edit" icon={editDial()} tooltipTitle="Edit" />
</SpeedDial>
</Box>
);
}

View File

@@ -1,88 +0,0 @@
import { RJSFSchema } from "@rjsf/utils";
export const schema: RJSFSchema = {
properties: {
bloatware: {
properties: {
age: {
default: 42,
description: "The age of the user",
type: "integer",
},
isAdmin: {
default: false,
description: "Is the user an admin?",
type: "boolean",
},
kernelModules: {
default: ["nvme", "xhci_pci", "ahci"],
description: "A list of enabled kernel modules",
items: {
type: "string",
},
type: "array",
},
name: {
default: "John Doe",
description: "The name of the user",
type: "string",
},
services: {
properties: {
opt: {
default: "foo",
description: "A submodule option",
type: "string",
},
},
type: "object",
},
userIds: {
additionalProperties: {
type: "integer",
},
default: {
albrecht: 3,
horst: 1,
peter: 2,
},
description: "Some attributes",
type: "object",
},
},
type: "object",
},
networking: {
properties: {
zerotier: {
properties: {
controller: {
properties: {
enable: {
default: false,
description:
"Whether to enable turn this machine into the networkcontroller.",
type: "boolean",
},
public: {
default: false,
description:
"everyone can join a public network without having the administrator to accept\n",
type: "boolean",
},
},
type: "object",
},
networkId: {
description: "zerotier networking id\n",
type: "string",
},
},
required: ["networkId"],
type: "object",
},
},
type: "object",
},
},
type: "object",
};

View File

@@ -1,111 +0,0 @@
import { RJSFSchema } from "@rjsf/utils";
export const schema: RJSFSchema = {
type: "object",
properties: {
name: {
type: "string",
default: "John-nixi",
description: "The name of the machine",
},
age: {
type: "integer",
default: 42,
description: "The age of the user",
maximum: 40,
},
role: {
enum: ["New York", "Amsterdam", "Hong Kong"],
description: "Role of the user",
},
kernelModules: {
type: "array",
items: {
type: "string",
},
default: ["nvme", "xhci_pci", "ahci"],
description: "A list of enabled kernel modules",
},
userIds: {
type: "array",
items: {
type: "object",
properties: {
user: {
type: "string",
},
id: {
type: "integer",
},
},
},
default: [
{
user: "John",
id: 12,
},
],
description: "Some attributes",
},
xdg: {
type: "object",
properties: {
portal: {
type: "object",
properties: {
xdgOpenUsePortal: {
type: "boolean",
default: false,
},
enable: {
type: "boolean",
default: false,
},
lxqt: {
type: "object",
properties: {
enable: {
type: "boolean",
default: false,
},
styles: {
type: "array",
items: {
type: "string",
},
},
},
},
extraPortals: {
type: "array",
items: {
type: "string",
},
},
wlr: {
type: "object",
properties: {
enable: {
type: "boolean",
default: false,
},
settings: {
type: "object",
default: {
screencast: {
output_name: "HDMI-A-1",
max_fps: 30,
exec_before: "disable_notifications.sh",
exec_after: "enable_notifications.sh",
chooser_type: "simple",
chooser_cmd: "${pkgs.slurp}/bin/slurp -f %o -or",
},
},
},
},
},
},
},
},
},
},
};

View File

@@ -1,170 +0,0 @@
export const status = {
online: "online",
offline: "offline",
pending: "pending",
} as const;
// Convert object keys in a union type
export type Status = (typeof status)[keyof typeof status];
export type Network = {
name: string;
id: string;
};
export type ClanDevice = {
id: string;
name: string;
status: Status;
ipv6: string;
networks: Network[];
};
export type ClanStatus = {
self: ClanDevice;
other: ClanDevice[];
};
export const clanStatus: ClanStatus = {
self: {
id: "1",
name: "My Computer",
ipv6: "",
status: "online",
networks: [
{
name: "Family",
id: "1",
},
{
name: "Fight-Club",
id: "1",
},
],
},
// other: [],
other: [
{
id: "2",
name: "Daddies Computer",
status: "online",
networks: [
{
name: "Family",
id: "1",
},
],
ipv6: "",
},
{
id: "3",
name: "Lars Notebook",
status: "offline",
networks: [
{
name: "Family",
id: "1",
},
],
ipv6: "",
},
{
id: "4",
name: "Cassie Computer",
status: "pending",
networks: [
{
name: "Family",
id: "1",
},
{
name: "Fight-Club",
id: "2",
},
],
ipv6: "",
},
{
id: "5",
name: "Chuck Norris Computer",
status: "online",
networks: [
{
name: "Fight-Club",
id: "2",
},
],
ipv6: "",
},
{
id: "6",
name: "Ella Bright",
status: "pending",
networks: [
{
name: "Fight-Club",
id: "2",
},
],
ipv6: "",
},
{
id: "7",
name: "Ryan Flabberghast",
status: "offline",
networks: [
{
name: "Fight-Club",
id: "2",
},
],
ipv6: "",
},
],
};
export const severity = {
info: "info",
success: "success",
warning: "warning",
error: "error",
} as const;
// Convert object keys in a union type
export type Severity = (typeof severity)[keyof typeof severity];
export type Notification = {
id: string;
msg: string;
source: string;
date: string;
severity: Severity;
};
export const notificationData: Notification[] = [
{
id: "1",
date: "2022-12-27 08:26:49.219717",
severity: "success",
msg: "Defeated zombie mob flawless",
source: "Chuck Norris Computer",
},
{
id: "2",
date: "2022-12-27 08:26:49.219717",
severity: "error",
msg: "Application Crashed: my little pony",
source: "Cassie Computer",
},
{
id: "3",
date: "2022-12-27 08:26:49.219717",
severity: "warning",
msg: "Security update necessary",
source: "Daddies Computer",
},
{
id: "4",
date: "2022-12-27 08:26:49.219717",
severity: "info",
msg: "Decompressed snowflakes",
source: "My Computer",
},
];

View File

@@ -1,178 +0,0 @@
export interface TableData {
name: string;
status: NodeStatusKeys;
last_seen: number;
}
export const NodeStatus = {
Online: "Online",
Offline: "Offline",
Pending: "Pending",
};
export type NodeStatusKeys = (typeof NodeStatus)[keyof typeof NodeStatus];
function createData(
name: string,
status: NodeStatusKeys,
last_seen: number,
): TableData {
if (status == NodeStatus.Online) {
last_seen = 0;
}
return {
name,
status,
last_seen: last_seen,
};
}
var nameNumber = 0;
// A function to generate random names
function getRandomName(): string {
let names = [
"Alice",
"Bob",
"Charlie",
"David",
"Eve",
"Frank",
"Grace",
"Heidi",
"Ivan",
"Judy",
"Mallory",
"Oscar",
"Peggy",
"Sybil",
"Trent",
"Victor",
"Walter",
"Wendy",
"Zoe",
];
let index = Math.floor(Math.random() * names.length);
return names[index] + nameNumber++;
}
// A function to generate random IPv6 addresses
// function getRandomId(): string {
// let hex = "0123456789abcdef";
// let id = "";
// for (let i = 0; i < 8; i++) {
// for (let j = 0; j < 4; j++) {
// let index = Math.floor(Math.random() * hex.length);
// id += hex[index];
// }
// if (i < 7) {
// id += ":";
// }
// }
// return id;
// }
// A function to generate random status keys
function getRandomStatus(): NodeStatusKeys {
let statusKeys = [NodeStatus.Online, NodeStatus.Offline, NodeStatus.Pending];
let index = Math.floor(Math.random() * statusKeys.length);
return statusKeys[index];
}
// A function to generate random last seen values
function getRandomLastSeen(status: NodeStatusKeys): number {
if (status === "online") {
return 0;
} else {
let min = 1; // One day ago
let max = 360; // One year ago
return Math.floor(Math.random() * (max - min + 1) + min);
}
}
export const tableData = [
createData(
"Matchbox",
NodeStatus.Pending,
0,
),
createData(
"Ahorn",
NodeStatus.Online,
0,
),
createData(
"Yellow",
NodeStatus.Offline,
16.0,
),
createData(
"Rauter",
NodeStatus.Offline,
6.0,
),
createData(
"Porree",
NodeStatus.Offline,
13,
),
createData(
"Helsinki",
NodeStatus.Online,
0,
),
createData(
"Kelle",
NodeStatus.Online,
0,
),
createData(
"Shodan",
NodeStatus.Online,
0.0,
),
createData(
"Qubasa",
NodeStatus.Offline,
7.0,
),
createData(
"Green",
NodeStatus.Offline,
2,
),
createData("Gum", NodeStatus.Offline, 0),
createData("Xu", NodeStatus.Online, 0),
createData(
"Zaatar",
NodeStatus.Online,
0,
),
];
// A function to execute the createData function with dummy data in a loop 100 times and return an array
export function executeCreateData(): TableData[] {
let result: TableData[] = [];
for (let i = 0; i < 100; i++) {
// Generate dummy data
let name = getRandomName();
let status = getRandomStatus();
let last_seen = getRandomLastSeen(status);
// Call the createData function and push the result to the array
result.push(createData(name, status, last_seen));
}
return result;
}

View File

@@ -1,602 +0,0 @@
export const tableData = [
{
name: "Wendy200",
status: "Pending",
last_seen: 115,
},
{
name: "Charlie201",
status: "Pending",
last_seen: 320,
},
{
name: "Bob202",
status: "Offline",
last_seen: 347,
},
{
name: "Sybil203",
status: "Online",
last_seen: 0,
},
{
name: "Trent204",
status: "Online",
last_seen: 0,
},
{
name: "Eve205",
status: "Online",
last_seen: 0,
},
{
name: "Alice206",
status: "Offline",
last_seen: 256,
},
{
name: "Frank207",
status: "Offline",
last_seen: 248,
},
{
name: "Eve208",
status: "Pending",
last_seen: 234,
},
{
name: "Bob209",
status: "Pending",
last_seen: 178,
},
{
name: "Peggy210",
status: "Offline",
last_seen: 256,
},
{
name: "Heidi211",
status: "Online",
last_seen: 0,
},
{
name: "Charlie212",
status: "Pending",
last_seen: 15,
},
{
name: "Victor213",
status: "Pending",
last_seen: 171,
},
{
name: "Heidi214",
status: "Pending",
last_seen: 287,
},
{
name: "Walter215",
status: "Online",
last_seen: 0,
},
{
name: "Alice216",
status: "Online",
last_seen: 0,
},
{
name: "Zoe217",
status: "Online",
last_seen: 0,
},
{
name: "Judy218",
status: "Pending",
last_seen: 184,
},
{
name: "Mallory219",
status: "Online",
last_seen: 0,
},
{
name: "Judy220",
status: "Offline",
last_seen: 63,
},
{
name: "Wendy221",
status: "Offline",
last_seen: 181,
},
{
name: "Bob222",
status: "Offline",
last_seen: 300,
},
{
name: "Eve223",
status: "Pending",
last_seen: 60,
},
{
name: "Judy224",
status: "Pending",
last_seen: 218,
},
{
name: "Peggy225",
status: "Offline",
last_seen: 140,
},
{
name: "Frank226",
status: "Offline",
last_seen: 106,
},
{
name: "Ivan227",
status: "Offline",
last_seen: 296,
},
{
name: "Charlie228",
status: "Pending",
last_seen: 81,
},
{
name: "Victor229",
status: "Pending",
last_seen: 46,
},
{
name: "Mallory230",
status: "Online",
last_seen: 0,
},
{
name: "Wendy231",
status: "Offline",
last_seen: 205,
},
{
name: "David232",
status: "Pending",
last_seen: 11,
},
{
name: "Eve233",
status: "Offline",
last_seen: 346,
},
{
name: "David234",
status: "Online",
last_seen: 0,
},
{
name: "Ivan235",
status: "Pending",
last_seen: 291,
},
{
name: "Mallory236",
status: "Offline",
last_seen: 321,
},
{
name: "David237",
status: "Online",
last_seen: 0,
},
{
name: "Victor238",
status: "Online",
last_seen: 0,
},
{
name: "Judy239",
status: "Offline",
last_seen: 3,
},
{
name: "Trent240",
status: "Online",
last_seen: 0,
},
{
name: "Ivan241",
status: "Pending",
last_seen: 38,
},
{
name: "Mallory242",
status: "Online",
last_seen: 0,
},
{
name: "Charlie243",
status: "Offline",
last_seen: 288,
},
{
name: "Ivan244",
status: "Offline",
last_seen: 225,
},
{
name: "Zoe245",
status: "Pending",
last_seen: 56,
},
{
name: "Trent246",
status: "Pending",
last_seen: 111,
},
{
name: "Sybil247",
status: "Online",
last_seen: 0,
},
{
name: "Wendy248",
status: "Offline",
last_seen: 299,
},
{
name: "David249",
status: "Pending",
last_seen: 303,
},
{
name: "Trent250",
status: "Offline",
last_seen: 69,
},
{
name: "Eve251",
status: "Pending",
last_seen: 354,
},
{
name: "Oscar252",
status: "Offline",
last_seen: 54,
},
{
name: "Sybil253",
status: "Online",
last_seen: 0,
},
{
name: "Zoe254",
status: "Offline",
last_seen: 118,
},
{
name: "Bob255",
status: "Pending",
last_seen: 112,
},
{
name: "Alice256",
status: "Online",
last_seen: 0,
},
{
name: "Eve257",
status: "Pending",
last_seen: 97,
},
{
name: "Peggy258",
status: "Online",
last_seen: 0,
},
{
name: "Ivan259",
status: "Pending",
last_seen: 99,
},
{
name: "Victor260",
status: "Offline",
last_seen: 231,
},
{
name: "Grace261",
status: "Offline",
last_seen: 199,
},
{
name: "Heidi262",
status: "Online",
last_seen: 0,
},
{
name: "Sybil263",
status: "Pending",
last_seen: 89,
},
{
name: "Alice264",
status: "Pending",
last_seen: 354,
},
{
name: "Zoe265",
status: "Pending",
last_seen: 12,
},
{
name: "Victor266",
status: "Pending",
last_seen: 24,
},
{
name: "Ivan267",
status: "Pending",
last_seen: 238,
},
{
name: "Peggy268",
status: "Offline",
last_seen: 113,
},
{
name: "Oscar269",
status: "Online",
last_seen: 0,
},
{
name: "Alice270",
status: "Online",
last_seen: 0,
},
{
name: "Eve271",
status: "Online",
last_seen: 0,
},
{
name: "Mallory272",
status: "Pending",
last_seen: 180,
},
{
name: "David273",
status: "Online",
last_seen: 0,
},
{
name: "Eve274",
status: "Pending",
last_seen: 164,
},
{
name: "Walter275",
status: "Online",
last_seen: 0,
},
{
name: "Eve276",
status: "Offline",
last_seen: 123,
},
{
name: "Wendy277",
status: "Offline",
last_seen: 211,
},
{
name: "Charlie278",
status: "Pending",
last_seen: 178,
},
{
name: "Eve279",
status: "Online",
last_seen: 0,
},
{
name: "Zoe280",
status: "Online",
last_seen: 0,
},
{
name: "Mallory281",
status: "Pending",
last_seen: 143,
},
{
name: "Bob282",
status: "Online",
last_seen: 0,
},
{
name: "Judy283",
status: "Online",
last_seen: 0,
},
{
name: "Grace284",
status: "Online",
last_seen: 0,
},
{
name: "Zoe285",
status: "Online",
last_seen: 0,
},
{
name: "Grace286",
status: "Pending",
last_seen: 92,
},
{
name: "Walter287",
status: "Online",
last_seen: 0,
},
{
name: "Walter288",
status: "Pending",
last_seen: 248,
},
{
name: "David289",
status: "Pending",
last_seen: 301,
},
{
name: "Peggy290",
status: "Online",
last_seen: 0,
},
{
name: "Sybil291",
status: "Pending",
last_seen: 114,
},
{
name: "Heidi292",
status: "Online",
last_seen: 0,
},
{
name: "David293",
status: "Offline",
last_seen: 165,
},
{
name: "Judy294",
status: "Pending",
last_seen: 52,
},
{
name: "Trent295",
status: "Online",
last_seen: 0,
},
{
name: "Heidi296",
status: "Pending",
last_seen: 129,
},
{
name: "Trent297",
status: "Pending",
last_seen: 108,
},
{
name: "David298",
status: "Online",
last_seen: 0,
},
{
name: "Sybil299",
status: "Online",
last_seen: 0,
},
];