mirror of https://github.com/jetkvm/kvm.git
Merge 7cd3d32926
into d79f359c43
This commit is contained in:
commit
ff5102438d
|
@ -1,7 +1,14 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
|
import {
|
||||||
|
Combobox as HeadlessCombobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOption,
|
||||||
|
ComboboxOptions,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export interface ComboboxOption {
|
||||||
|
@ -22,7 +29,7 @@ const comboboxVariants = cva({
|
||||||
|
|
||||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||||
|
|
||||||
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
|
interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
|
||||||
displayValue: (option: ComboboxOption) => string;
|
displayValue: (option: ComboboxOption) => string;
|
||||||
onInputChange: (option: string) => void;
|
onInputChange: (option: string) => void;
|
||||||
options: () => ComboboxOption[];
|
options: () => ComboboxOption[];
|
||||||
|
@ -48,72 +55,68 @@ export function Combobox({
|
||||||
const classes = comboboxVariants({ size });
|
const classes = comboboxVariants({ size });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessCombobox
|
<HeadlessCombobox onChange={onChange} {...otherProps}>
|
||||||
onChange={onChange}
|
|
||||||
{...otherProps}
|
|
||||||
>
|
|
||||||
{() => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
classes,
|
classes,
|
||||||
|
|
||||||
// General styling
|
// General styling
|
||||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||||
|
|
||||||
// Hover
|
// Hover
|
||||||
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
||||||
|
|
||||||
// Dark mode
|
// Dark mode
|
||||||
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
||||||
|
|
||||||
// Focus
|
// Focus
|
||||||
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||||
|
|
||||||
// Disabled
|
// Disabled
|
||||||
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
|
disabled &&
|
||||||
)}
|
"pointer-events-none select-none bg-slate-50 text-slate-500/80 disabled:hover:bg-white dark:bg-slate-800 dark:text-slate-400/80 dark:disabled:hover:bg-slate-800",
|
||||||
placeholder={disabled ? disabledMessage : placeholder}
|
)}
|
||||||
displayValue={displayValue}
|
placeholder={disabled ? disabledMessage : placeholder}
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
displayValue={displayValue}
|
||||||
disabled={disabled}
|
onChange={event => onInputChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{options().length > 0 && (
|
{options().length > 0 && (
|
||||||
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
|
<ComboboxOptions className="hide-scrollbar absolute left-0 z-[100] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
|
||||||
{options().map((option) => (
|
{options().map(option => (
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option}
|
value={option}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
// General styling
|
// General styling
|
||||||
"cursor-default select-none py-2 px-4",
|
"cursor-default select-none px-4 py-2",
|
||||||
|
|
||||||
// Hover and active states
|
// Hover and active states
|
||||||
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
||||||
|
|
||||||
// Dark mode
|
// Dark mode
|
||||||
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
|
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
))}
|
))}
|
||||||
</ComboboxOptions>
|
</ComboboxOptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{options().length === 0 && inputRef.current?.value && (
|
{options().length === 0 && inputRef.current?.value && (
|
||||||
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
|
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
|
||||||
<div className="text-slate-500 dark:text-slate-400">
|
<div className="text-slate-500 dark:text-slate-400">{emptyMessage}</div>
|
||||||
{emptyMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</HeadlessCombobox>
|
</HeadlessCombobox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
@ -42,12 +47,15 @@ const variantConfig = {
|
||||||
iconBgClass: "bg-blue-100",
|
iconBgClass: "bg-blue-100",
|
||||||
buttonTheme: "primary",
|
buttonTheme: "primary",
|
||||||
},
|
},
|
||||||
} as Record<Variant, {
|
} as Record<
|
||||||
|
Variant,
|
||||||
|
{
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
iconClass: string;
|
iconClass: string;
|
||||||
iconBgClass: string;
|
iconBgClass: string;
|
||||||
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
|
@ -65,13 +73,18 @@ export function ConfirmDialog({
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
||||||
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
|
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
|
<div
|
||||||
|
className={cx(
|
||||||
|
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
||||||
|
iconBgClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||||
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
|
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -83,12 +96,7 @@ export function ConfirmDialog({
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-2">
|
<div className="flex justify-end gap-x-2">
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<Button
|
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
|
||||||
size="SM"
|
|
||||||
theme="blank"
|
|
||||||
text={cancelText}
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
|
@ -103,4 +111,4 @@ export function ConfirmDialog({
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
|
||||||
import { KeySequence } from "@/hooks/stores";
|
import { KeySequence } from "@/hooks/stores";
|
||||||
|
@ -7,16 +6,23 @@ import { Button } from "@/components/Button";
|
||||||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
import { MacroStepCard } from "@/components/MacroStepCard";
|
import { MacroStepCard } from "@/components/MacroStepCard";
|
||||||
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
import {
|
||||||
|
DEFAULT_DELAY,
|
||||||
|
MAX_STEPS_PER_MACRO,
|
||||||
|
MAX_KEYS_PER_STEP,
|
||||||
|
} from "@/constants/macros";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
steps?: Record<number, {
|
steps?: Record<
|
||||||
keys?: string;
|
number,
|
||||||
modifiers?: string;
|
{
|
||||||
delay?: string;
|
keys?: string;
|
||||||
}>;
|
modifiers?: string;
|
||||||
|
delay?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MacroFormProps {
|
interface MacroFormProps {
|
||||||
|
@ -53,16 +59,18 @@ export function MacroForm({
|
||||||
} else if (macro.name.trim().length > 50) {
|
} else if (macro.name.trim().length > 50) {
|
||||||
newErrors.name = "Name must be less than 50 characters";
|
newErrors.name = "Name must be less than 50 characters";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!macro.steps?.length) {
|
if (!macro.steps?.length) {
|
||||||
newErrors.steps = { 0: { keys: "At least one step is required" } };
|
newErrors.steps = { 0: { keys: "At least one step is required" } };
|
||||||
} else {
|
} else {
|
||||||
const hasKeyOrModifier = macro.steps.some(step =>
|
const hasKeyOrModifier = macro.steps.some(
|
||||||
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
|
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasKeyOrModifier) {
|
if (!hasKeyOrModifier) {
|
||||||
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
|
newErrors.steps = {
|
||||||
|
0: { keys: "At least one step must have keys or modifiers" },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,7 +95,10 @@ export function MacroForm({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
|
const handleKeySelect = (
|
||||||
|
stepIndex: number,
|
||||||
|
option: { value: string | null; keys?: string[] },
|
||||||
|
) => {
|
||||||
const newSteps = [...(macro.steps || [])];
|
const newSteps = [...(macro.steps || [])];
|
||||||
if (!newSteps[stepIndex]) return;
|
if (!newSteps[stepIndex]) return;
|
||||||
|
|
||||||
|
@ -97,7 +108,9 @@ export function MacroForm({
|
||||||
if (!newSteps[stepIndex].keys) {
|
if (!newSteps[stepIndex].keys) {
|
||||||
newSteps[stepIndex].keys = [];
|
newSteps[stepIndex].keys = [];
|
||||||
}
|
}
|
||||||
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
|
const keysArray = Array.isArray(newSteps[stepIndex].keys)
|
||||||
|
? newSteps[stepIndex].keys
|
||||||
|
: [];
|
||||||
if (keysArray.length >= MAX_KEYS_PER_STEP) {
|
if (keysArray.length >= MAX_KEYS_PER_STEP) {
|
||||||
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
|
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
|
||||||
return;
|
return;
|
||||||
|
@ -105,7 +118,7 @@ export function MacroForm({
|
||||||
newSteps[stepIndex].keys = [...keysArray, option.value];
|
newSteps[stepIndex].keys = [...keysArray, option.value];
|
||||||
}
|
}
|
||||||
setMacro({ ...macro, steps: newSteps });
|
setMacro({ ...macro, steps: newSteps });
|
||||||
|
|
||||||
if (errors.steps?.[stepIndex]?.keys) {
|
if (errors.steps?.[stepIndex]?.keys) {
|
||||||
const newErrors = { ...errors };
|
const newErrors = { ...errors };
|
||||||
delete newErrors.steps?.[stepIndex].keys;
|
delete newErrors.steps?.[stepIndex].keys;
|
||||||
|
@ -127,7 +140,7 @@ export function MacroForm({
|
||||||
const newSteps = [...(macro.steps || [])];
|
const newSteps = [...(macro.steps || [])];
|
||||||
newSteps[stepIndex].modifiers = modifiers;
|
newSteps[stepIndex].modifiers = modifiers;
|
||||||
setMacro({ ...macro, steps: newSteps });
|
setMacro({ ...macro, steps: newSteps });
|
||||||
|
|
||||||
// Clear step errors when modifiers are added
|
// Clear step errors when modifiers are added
|
||||||
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
|
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
|
||||||
const newErrors = { ...errors };
|
const newErrors = { ...errors };
|
||||||
|
@ -148,9 +161,9 @@ export function MacroForm({
|
||||||
setMacro({ ...macro, steps: newSteps });
|
setMacro({ ...macro, steps: newSteps });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
|
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
|
||||||
const newSteps = [...(macro.steps || [])];
|
const newSteps = [...(macro.steps || [])];
|
||||||
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
|
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
|
||||||
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
|
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
|
||||||
setMacro({ ...macro, steps: newSteps });
|
setMacro({ ...macro, steps: newSteps });
|
||||||
};
|
};
|
||||||
|
@ -181,7 +194,10 @@ export function MacroForm({
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
|
<FieldLabel
|
||||||
|
label="Steps"
|
||||||
|
description={`Keys/modifiers executed in sequence with a delay between each step.`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-slate-500 dark:text-slate-400">
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
|
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
|
||||||
|
@ -199,18 +215,24 @@ export function MacroForm({
|
||||||
key={stepIndex}
|
key={stepIndex}
|
||||||
step={step}
|
step={step}
|
||||||
stepIndex={stepIndex}
|
stepIndex={stepIndex}
|
||||||
onDelete={macro.steps && macro.steps.length > 1 ? () => {
|
onDelete={
|
||||||
const newSteps = [...(macro.steps || [])];
|
macro.steps && macro.steps.length > 1
|
||||||
newSteps.splice(stepIndex, 1);
|
? () => {
|
||||||
setMacro(prev => ({ ...prev, steps: newSteps }));
|
const newSteps = [...(macro.steps || [])];
|
||||||
} : undefined}
|
newSteps.splice(stepIndex, 1);
|
||||||
onMoveUp={() => handleStepMove(stepIndex, 'up')}
|
setMacro(prev => ({ ...prev, steps: newSteps }));
|
||||||
onMoveDown={() => handleStepMove(stepIndex, 'down')}
|
}
|
||||||
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
|
: undefined
|
||||||
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
|
}
|
||||||
keyQuery={keyQueries[stepIndex] || ''}
|
onMoveUp={() => handleStepMove(stepIndex, "up")}
|
||||||
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
|
onMoveDown={() => handleStepMove(stepIndex, "down")}
|
||||||
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
|
onKeySelect={option => handleKeySelect(stepIndex, option)}
|
||||||
|
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
|
||||||
|
keyQuery={keyQueries[stepIndex] || ""}
|
||||||
|
onModifierChange={modifiers =>
|
||||||
|
handleModifierChange(stepIndex, modifiers)
|
||||||
|
}
|
||||||
|
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
||||||
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -223,18 +245,20 @@ export function MacroForm({
|
||||||
theme="light"
|
theme="light"
|
||||||
fullWidth
|
fullWidth
|
||||||
LeadingIcon={LuPlus}
|
LeadingIcon={LuPlus}
|
||||||
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
|
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isMaxStepsReached) {
|
if (isMaxStepsReached) {
|
||||||
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
|
showTemporaryError(
|
||||||
|
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMacro(prev => ({
|
setMacro(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
steps: [
|
steps: [
|
||||||
...(prev.steps || []),
|
...(prev.steps || []),
|
||||||
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
|
{ keys: [], modifiers: [], delay: DEFAULT_DELAY },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
@ -257,15 +281,10 @@ export function MacroForm({
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||||
size="SM"
|
|
||||||
theme="light"
|
|
||||||
text="Cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { LuPlay } from "react-icons/lu";
|
import { LuPlay } from "react-icons/lu";
|
||||||
|
import { BsMouseFill } from "react-icons/bs";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
import { BsMouseFill } from "react-icons/bs";
|
|
||||||
|
|
||||||
interface OverlayContentProps {
|
interface OverlayContentProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -242,8 +242,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
Ensure source device is powered on and outputting a signal
|
Ensure source device is powered on and outputting a signal
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If using an adapter, ensure it's compatible and
|
If using an adapter, ensure it's compatible and functioning
|
||||||
functioning correctly
|
correctly
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -151,7 +151,7 @@ export default function WebRTCVideo() {
|
||||||
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
||||||
if (isKeyboardLockGranted) {
|
if (isKeyboardLockGranted) {
|
||||||
if ("keyboard" in navigator) {
|
if ("keyboard" in navigator) {
|
||||||
// @ts-ignore
|
// @ts-expect-error - keyboard lock is not supported in all browsers
|
||||||
await navigator.keyboard.lock();
|
await navigator.keyboard.lock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
|
||||||
|
import {
|
||||||
|
MAX_STEPS_PER_MACRO,
|
||||||
|
MAX_TOTAL_MACROS,
|
||||||
|
MAX_KEYS_PER_STEP,
|
||||||
|
} from "@/constants/macros";
|
||||||
|
|
||||||
// Define the JsonRpc types for better type checking
|
// Define the JsonRpc types for better type checking
|
||||||
interface JsonRpcResponse {
|
interface JsonRpcResponse {
|
||||||
|
@ -564,12 +569,12 @@ export interface UpdateState {
|
||||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||||
modalView:
|
modalView:
|
||||||
| "loading"
|
| "loading"
|
||||||
| "updating"
|
| "updating"
|
||||||
| "upToDate"
|
| "upToDate"
|
||||||
| "updateAvailable"
|
| "updateAvailable"
|
||||||
| "updateCompleted"
|
| "updateCompleted"
|
||||||
| "error";
|
| "error";
|
||||||
setModalView: (view: UpdateState["modalView"]) => void;
|
setModalView: (view: UpdateState["modalView"]) => void;
|
||||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||||
updateErrorMessage: string | null;
|
updateErrorMessage: string | null;
|
||||||
|
@ -633,12 +638,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
|
||||||
|
|
||||||
interface LocalAuthModalState {
|
interface LocalAuthModalState {
|
||||||
modalView:
|
modalView:
|
||||||
| "createPassword"
|
| "createPassword"
|
||||||
| "deletePassword"
|
| "deletePassword"
|
||||||
| "updatePassword"
|
| "updatePassword"
|
||||||
| "creationSuccess"
|
| "creationSuccess"
|
||||||
| "deleteSuccess"
|
| "deleteSuccess"
|
||||||
| "updateSuccess";
|
| "updateSuccess";
|
||||||
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -719,12 +724,23 @@ export interface NetworkState {
|
||||||
setDhcpLeaseExpiry: (expiry: Date) => void;
|
setDhcpLeaseExpiry: (expiry: Date) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IPv6Mode =
|
||||||
export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown";
|
| "disabled"
|
||||||
|
| "slaac"
|
||||||
|
| "dhcpv6"
|
||||||
|
| "slaac_and_dhcpv6"
|
||||||
|
| "static"
|
||||||
|
| "link_local"
|
||||||
|
| "unknown";
|
||||||
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
|
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
|
||||||
export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
|
export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
|
||||||
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
|
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
|
||||||
export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown";
|
export type TimeSyncMode =
|
||||||
|
| "ntp_only"
|
||||||
|
| "ntp_and_http"
|
||||||
|
| "http_only"
|
||||||
|
| "custom"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
@ -749,7 +765,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||||
|
|
||||||
lease.lease_expiry = expiry;
|
lease.lease_expiry = expiry;
|
||||||
set({ dhcp_lease: lease });
|
set({ dhcp_lease: lease });
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export interface KeySequenceStep {
|
export interface KeySequenceStep {
|
||||||
|
@ -771,8 +787,20 @@ export interface MacrosState {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
loadMacros: () => Promise<void>;
|
loadMacros: () => Promise<void>;
|
||||||
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
||||||
sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null;
|
sendFn:
|
||||||
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
|
| ((
|
||||||
|
method: string,
|
||||||
|
params: unknown,
|
||||||
|
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
||||||
|
) => void)
|
||||||
|
| null;
|
||||||
|
setSendFn: (
|
||||||
|
sendFn: (
|
||||||
|
method: string,
|
||||||
|
params: unknown,
|
||||||
|
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
||||||
|
) => void,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateMacroId = () => {
|
export const generateMacroId = () => {
|
||||||
|
@ -785,7 +813,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
initialized: false,
|
initialized: false,
|
||||||
sendFn: null,
|
sendFn: null,
|
||||||
|
|
||||||
setSendFn: (sendFn) => {
|
setSendFn: sendFn => {
|
||||||
set({ sendFn });
|
set({ sendFn });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -802,7 +830,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sendFn("getKeyboardMacros", {}, (response) => {
|
sendFn("getKeyboardMacros", {}, response => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error("Error loading macros:", response.error);
|
console.error("Error loading macros:", response.error);
|
||||||
reject(new Error(response.error.message));
|
reject(new Error(response.error.message));
|
||||||
|
@ -822,7 +850,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
set({
|
set({
|
||||||
macros: sortedMacros,
|
macros: sortedMacros,
|
||||||
initialized: true
|
initialized: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
|
@ -849,15 +877,23 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
for (const macro of macros) {
|
for (const macro of macros) {
|
||||||
if (macro.steps.length > MAX_STEPS_PER_MACRO) {
|
if (macro.steps.length > MAX_STEPS_PER_MACRO) {
|
||||||
console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
|
console.error(
|
||||||
throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
|
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < macro.steps.length; i++) {
|
for (let i = 0; i < macro.steps.length; i++) {
|
||||||
const step = macro.steps[i];
|
const step = macro.steps[i];
|
||||||
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
|
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
|
||||||
console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
|
console.error(
|
||||||
throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
|
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -867,20 +903,25 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
try {
|
try {
|
||||||
const macrosWithSortOrder = macros.map((macro, index) => ({
|
const macrosWithSortOrder = macros.map((macro, index) => ({
|
||||||
...macro,
|
...macro,
|
||||||
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index
|
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await new Promise<JsonRpcResponse>((resolve) => {
|
const response = await new Promise<JsonRpcResponse>(resolve => {
|
||||||
sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
|
sendFn(
|
||||||
resolve(response);
|
"setKeyboardMacros",
|
||||||
});
|
{ params: { macros: macrosWithSortOrder } },
|
||||||
|
response => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error("Error saving macros:", response.error);
|
console.error("Error saving macros:", response.error);
|
||||||
const errorMessage = typeof response.error.data === 'string'
|
const errorMessage =
|
||||||
? response.error.data
|
typeof response.error.data === "string"
|
||||||
: response.error.message || "Failed to save macros";
|
? response.error.data
|
||||||
|
: response.error.message || "Failed to save macros";
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -892,5 +933,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
|
import {
|
||||||
|
LuPenLine,
|
||||||
|
LuCopy,
|
||||||
|
LuMoveRight,
|
||||||
|
LuCornerDownRight,
|
||||||
|
LuArrowUp,
|
||||||
|
LuArrowDown,
|
||||||
|
LuTrash2,
|
||||||
|
LuCommand,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
|
||||||
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
||||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||||
|
@ -26,10 +35,10 @@ export default function SettingsMacrosRoute() {
|
||||||
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
|
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
||||||
|
|
||||||
const isMaxMacrosReached = useMemo(() =>
|
const isMaxMacrosReached = useMemo(
|
||||||
macros.length >= MAX_TOTAL_MACROS,
|
() => macros.length >= MAX_TOTAL_MACROS,
|
||||||
[macros.length]
|
[macros.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -38,75 +47,83 @@ export default function SettingsMacrosRoute() {
|
||||||
}
|
}
|
||||||
}, [initialized, loadMacros]);
|
}, [initialized, loadMacros]);
|
||||||
|
|
||||||
const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
|
const handleDuplicateMacro = useCallback(
|
||||||
if (!macro?.id || !macro?.name) {
|
async (macro: KeySequence) => {
|
||||||
notifications.error("Invalid macro data");
|
if (!macro?.id || !macro?.name) {
|
||||||
return;
|
notifications.error("Invalid macro data");
|
||||||
}
|
return;
|
||||||
|
|
||||||
if (isMaxMacrosReached) {
|
|
||||||
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionLoadingId(macro.id);
|
|
||||||
|
|
||||||
const newMacroCopy: KeySequence = {
|
|
||||||
...JSON.parse(JSON.stringify(macro)),
|
|
||||||
id: generateMacroId(),
|
|
||||||
name: `${macro.name} ${COPY_SUFFIX}`,
|
|
||||||
sortOrder: macros.length + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
|
||||||
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to duplicate macro");
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setActionLoadingId(null);
|
|
||||||
}
|
|
||||||
}, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
|
|
||||||
|
|
||||||
const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
|
if (isMaxMacrosReached) {
|
||||||
if (!Array.isArray(macros) || macros.length === 0) {
|
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
||||||
notifications.error("No macros available");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
if (newIndex < 0 || newIndex >= macros.length) return;
|
|
||||||
|
|
||||||
setActionLoadingId(macroId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newMacros = [...macros];
|
|
||||||
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
|
||||||
const updatedMacros = normalizeSortOrders(newMacros);
|
|
||||||
|
|
||||||
await saveMacros(updatedMacros);
|
|
||||||
notifications.success("Macro order updated successfully");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to reorder macros");
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setActionLoadingId(null);
|
setActionLoadingId(macro.id);
|
||||||
}
|
|
||||||
}, [macros, saveMacros, setActionLoadingId]);
|
const newMacroCopy: KeySequence = {
|
||||||
|
...JSON.parse(JSON.stringify(macro)),
|
||||||
|
id: generateMacroId(),
|
||||||
|
name: `${macro.name} ${COPY_SUFFIX}`,
|
||||||
|
sortOrder: macros.length + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
||||||
|
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
notifications.error("Failed to duplicate macro");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveMacro = useCallback(
|
||||||
|
async (index: number, direction: "up" | "down", macroId: string) => {
|
||||||
|
if (!Array.isArray(macros) || macros.length === 0) {
|
||||||
|
notifications.error("No macros available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= macros.length) return;
|
||||||
|
|
||||||
|
setActionLoadingId(macroId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newMacros = [...macros];
|
||||||
|
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
||||||
|
const updatedMacros = normalizeSortOrders(newMacros);
|
||||||
|
|
||||||
|
await saveMacros(updatedMacros);
|
||||||
|
notifications.success("Macro order updated successfully");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
notifications.error("Failed to reorder macros");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[macros, saveMacros, setActionLoadingId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteMacro = useCallback(async () => {
|
const handleDeleteMacro = useCallback(async () => {
|
||||||
if (!macroToDelete?.id) return;
|
if (!macroToDelete?.id) return;
|
||||||
|
|
||||||
setActionLoadingId(macroToDelete.id);
|
setActionLoadingId(macroToDelete.id);
|
||||||
try {
|
try {
|
||||||
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
|
const updatedMacros = normalizeSortOrders(
|
||||||
|
macros.filter(m => m.id !== macroToDelete.id),
|
||||||
|
);
|
||||||
await saveMacros(updatedMacros);
|
await saveMacros(updatedMacros);
|
||||||
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
|
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
|
@ -122,135 +139,168 @@ export default function SettingsMacrosRoute() {
|
||||||
}
|
}
|
||||||
}, [macroToDelete, macros, saveMacros]);
|
}, [macroToDelete, macros, saveMacros]);
|
||||||
|
|
||||||
const MacroList = useMemo(() => (
|
const MacroList = useMemo(
|
||||||
<div className="space-y-2">
|
() => (
|
||||||
{macros.map((macro, index) => (
|
<div className="space-y-2">
|
||||||
<Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
|
{macros.map((macro, index) => (
|
||||||
<div className="flex items-center justify-between">
|
<Card key={macro.id} className="bg-white p-2 dark:bg-slate-800">
|
||||||
<div className="flex flex-col gap-1 px-2">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<div className="flex flex-col gap-1 px-2">
|
||||||
size="XS"
|
<Button
|
||||||
theme="light"
|
size="XS"
|
||||||
onClick={() => handleMoveMacro(index, 'up', macro.id)}
|
theme="light"
|
||||||
disabled={index === 0 || actionLoadingId === macro.id}
|
onClick={() => handleMoveMacro(index, "up", macro.id)}
|
||||||
LeadingIcon={LuArrowUp}
|
disabled={index === 0 || actionLoadingId === macro.id}
|
||||||
aria-label={`Move ${macro.name} up`}
|
LeadingIcon={LuArrowUp}
|
||||||
/>
|
aria-label={`Move ${macro.name} up`}
|
||||||
<Button
|
/>
|
||||||
size="XS"
|
<Button
|
||||||
theme="light"
|
size="XS"
|
||||||
onClick={() => handleMoveMacro(index, 'down', macro.id)}
|
theme="light"
|
||||||
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
|
onClick={() => handleMoveMacro(index, "down", macro.id)}
|
||||||
LeadingIcon={LuArrowDown}
|
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
|
||||||
aria-label={`Move ${macro.name} down`}
|
LeadingIcon={LuArrowDown}
|
||||||
/>
|
aria-label={`Move ${macro.name} down`}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
|
<div className="ml-2 flex min-w-0 flex-1 flex-col justify-center">
|
||||||
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
||||||
{macro.name}
|
{macro.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
|
<p className="ml-4 mt-1 overflow-hidden text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span className="flex flex-col items-start gap-1">
|
<span className="flex flex-col items-start gap-1">
|
||||||
{macro.steps.map((step, stepIndex) => {
|
{macro.steps.map((step, stepIndex) => {
|
||||||
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
|
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={stepIndex} className="inline-flex items-center">
|
<span key={stepIndex} className="inline-flex items-center">
|
||||||
<StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
|
<StepIcon className="mr-1 h-3 w-3 flex-shrink-0 text-slate-400 dark:text-slate-500" />
|
||||||
<span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
|
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
|
||||||
{(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
|
{(Array.isArray(step.modifiers) &&
|
||||||
<>
|
step.modifiers.length > 0) ||
|
||||||
{Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
|
(Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||||
<Fragment key={`mod-${idx}`}>
|
<>
|
||||||
<span className="font-medium text-slate-600 dark:text-slate-200">
|
{Array.isArray(step.modifiers) &&
|
||||||
{modifierDisplayMap[modifier] || modifier}
|
step.modifiers.map((modifier, idx) => (
|
||||||
</span>
|
<Fragment key={`mod-${idx}`}>
|
||||||
{idx < step.modifiers.length - 1 && (
|
<span className="font-medium text-slate-600 dark:text-slate-200">
|
||||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
{modifierDisplayMap[modifier] || modifier}
|
||||||
|
</span>
|
||||||
|
{idx < step.modifiers.length - 1 && (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">
|
||||||
|
{" "}
|
||||||
|
+{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.isArray(step.modifiers) &&
|
||||||
|
step.modifiers.length > 0 &&
|
||||||
|
Array.isArray(step.keys) &&
|
||||||
|
step.keys.length > 0 && (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">
|
||||||
|
{" "}
|
||||||
|
+{" "}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
|
{Array.isArray(step.keys) &&
|
||||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
step.keys.map((key, idx) => (
|
||||||
)}
|
<Fragment key={`key-${idx}`}>
|
||||||
|
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||||
{Array.isArray(step.keys) && step.keys.map((key, idx) => (
|
{keyDisplayMap[key] || key}
|
||||||
<Fragment key={`key-${idx}`}>
|
</span>
|
||||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
{idx < step.keys.length - 1 && (
|
||||||
{keyDisplayMap[key] || key}
|
<span className="text-slate-400 dark:text-slate-600">
|
||||||
</span>
|
{" "}
|
||||||
{idx < step.keys.length - 1 && (
|
+{" "}
|
||||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
|
<span className="font-medium text-slate-500 dark:text-slate-400">
|
||||||
)}
|
Delay only
|
||||||
{step.delay !== DEFAULT_DELAY && (
|
</span>
|
||||||
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
|
)}
|
||||||
)}
|
{step.delay !== DEFAULT_DELAY && (
|
||||||
|
<span className="ml-1 text-slate-400 dark:text-slate-500">
|
||||||
|
({step.delay}ms)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</span>
|
||||||
</span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 ml-4">
|
<div className="ml-4 flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
className="text-red-500 dark:text-red-400"
|
className="text-red-500 dark:text-red-400"
|
||||||
theme="light"
|
theme="light"
|
||||||
LeadingIcon={LuTrash2}
|
LeadingIcon={LuTrash2}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMacroToDelete(macro);
|
setMacroToDelete(macro);
|
||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
}}
|
}}
|
||||||
disabled={actionLoadingId === macro.id}
|
disabled={actionLoadingId === macro.id}
|
||||||
aria-label={`Delete macro ${macro.name}`}
|
aria-label={`Delete macro ${macro.name}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
LeadingIcon={LuCopy}
|
LeadingIcon={LuCopy}
|
||||||
onClick={() => handleDuplicateMacro(macro)}
|
onClick={() => handleDuplicateMacro(macro)}
|
||||||
disabled={actionLoadingId === macro.id}
|
disabled={actionLoadingId === macro.id}
|
||||||
aria-label={`Duplicate macro ${macro.name}`}
|
aria-label={`Duplicate macro ${macro.name}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
LeadingIcon={LuPenLine}
|
LeadingIcon={LuPenLine}
|
||||||
text="Edit"
|
text="Edit"
|
||||||
onClick={() => navigate(`${macro.id}/edit`)}
|
onClick={() => navigate(`${macro.id}/edit`)}
|
||||||
disabled={actionLoadingId === macro.id}
|
disabled={actionLoadingId === macro.id}
|
||||||
aria-label={`Edit macro ${macro.name}`}
|
aria-label={`Edit macro ${macro.name}`}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
setMacroToDelete(null);
|
setMacroToDelete(null);
|
||||||
}}
|
}}
|
||||||
title="Delete Macro"
|
title="Delete Macro"
|
||||||
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
|
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
|
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
|
||||||
onConfirm={handleDeleteMacro}
|
onConfirm={handleDeleteMacro}
|
||||||
isConfirming={actionLoadingId === macroToDelete?.id}
|
isConfirming={actionLoadingId === macroToDelete?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
|
),
|
||||||
|
[
|
||||||
|
macros,
|
||||||
|
showDeleteConfirm,
|
||||||
|
macroToDelete?.name,
|
||||||
|
macroToDelete?.id,
|
||||||
|
actionLoadingId,
|
||||||
|
handleDeleteMacro,
|
||||||
|
handleMoveMacro,
|
||||||
|
handleDuplicateMacro,
|
||||||
|
navigate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
@ -259,7 +309,7 @@ export default function SettingsMacrosRoute() {
|
||||||
title="Keyboard Macros"
|
title="Keyboard Macros"
|
||||||
description={`Combine keystrokes into a single action for faster workflows.`}
|
description={`Combine keystrokes into a single action for faster workflows.`}
|
||||||
/>
|
/>
|
||||||
{ macros.length > 0 && (
|
{macros.length > 0 && (
|
||||||
<div className="flex items-center pl-2">
|
<div className="flex items-center pl-2">
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
|
@ -299,7 +349,9 @@ export default function SettingsMacrosRoute() {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : MacroList}
|
) : (
|
||||||
|
MacroList
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,18 +1,30 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import {
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
IPv4Mode,
|
||||||
|
IPv6Mode,
|
||||||
import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores";
|
LLDPMode,
|
||||||
|
mDNSMode,
|
||||||
|
NetworkSettings,
|
||||||
|
NetworkState,
|
||||||
|
TimeSyncMode,
|
||||||
|
useNetworkStateStore,
|
||||||
|
} from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import InputField from "@components/InputField";
|
import InputField from "@components/InputField";
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
|
import Fieldset from "../components/Fieldset";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
@ -25,13 +37,9 @@ const defaultNetworkSettings: NetworkSettings = {
|
||||||
lldp_tx_tlvs: [],
|
lldp_tx_tlvs: [],
|
||||||
mdns_mode: "unknown",
|
mdns_mode: "unknown",
|
||||||
time_sync_mode: "unknown",
|
time_sync_mode: "unknown",
|
||||||
}
|
};
|
||||||
|
|
||||||
export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
||||||
if (lifetime == "") {
|
|
||||||
return <strong>N/A</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [remaining, setRemaining] = useState<string | null>(null);
|
const [remaining, setRemaining] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -43,46 +51,87 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [lifetime]);
|
}, [lifetime]);
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<strong>{dayjs(lifetime).format()}</strong>
|
<>
|
||||||
{remaining && <>
|
<span>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</span>
|
||||||
{" "}<span className="text-xs text-slate-700 dark:text-slate-300">
|
{remaining && (
|
||||||
({remaining})
|
<>
|
||||||
</span>
|
{" "}
|
||||||
</>}
|
<span className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
</>
|
({remaining})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsNetworkRoute() {
|
export default function SettingsNetworkRoute() {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]);
|
const [networkState, setNetworkState] = useNetworkStateStore(state => [
|
||||||
|
state,
|
||||||
|
state.setNetworkState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [networkSettings, setNetworkSettings] =
|
||||||
|
useState<NetworkSettings>(defaultNetworkSettings);
|
||||||
|
|
||||||
|
// We use this to determine whether the settings have changed
|
||||||
|
const firstNetworkSettings = useRef<NetworkSettings>();
|
||||||
|
|
||||||
const [networkSettings, setNetworkSettings] = useState<NetworkSettings>(defaultNetworkSettings);
|
|
||||||
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [customDomain, setCustomDomain] = useState<string>("");
|
||||||
|
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (networkSettings.domain && networkSettingsLoaded) {
|
||||||
|
// Check if the domain is one of the predefined options
|
||||||
|
const predefinedOptions = ["dhcp", "local"];
|
||||||
|
if (predefinedOptions.includes(networkSettings.domain)) {
|
||||||
|
setSelectedDomainOption(networkSettings.domain);
|
||||||
|
} else {
|
||||||
|
setSelectedDomainOption("custom");
|
||||||
|
setCustomDomain(networkSettings.domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [networkSettings.domain, networkSettingsLoaded]);
|
||||||
|
|
||||||
const getNetworkSettings = useCallback(() => {
|
const getNetworkSettings = useCallback(() => {
|
||||||
setNetworkSettingsLoaded(false);
|
setNetworkSettingsLoaded(false);
|
||||||
send("getNetworkSettings", {}, resp => {
|
send("getNetworkSettings", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
console.log(resp.result);
|
console.log(resp.result);
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
|
|
||||||
|
if (!firstNetworkSettings.current) {
|
||||||
|
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||||
|
}
|
||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => {
|
const setNetworkSettingsRemote = useCallback(
|
||||||
setNetworkSettingsLoaded(false);
|
(settings: NetworkSettings) => {
|
||||||
send("setNetworkSettings", { settings }, resp => {
|
setNetworkSettingsLoaded(false);
|
||||||
if ("error" in resp) {
|
send("setNetworkSettings", { settings }, resp => {
|
||||||
notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message));
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
"Failed to save network settings: " +
|
||||||
|
(resp.error.data ? resp.error.data : resp.error.message),
|
||||||
|
);
|
||||||
|
setNetworkSettingsLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
|
||||||
|
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||||
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
return;
|
notifications.success("Network settings saved");
|
||||||
}
|
});
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
},
|
||||||
setNetworkSettingsLoaded(true);
|
[send],
|
||||||
notifications.success("Network settings saved");
|
);
|
||||||
});
|
|
||||||
}, [send]);
|
|
||||||
|
|
||||||
const getNetworkState = useCallback(() => {
|
const getNetworkState = useCallback(() => {
|
||||||
send("getNetworkState", {}, resp => {
|
send("getNetworkState", {}, resp => {
|
||||||
|
@ -90,7 +139,7 @@ export default function SettingsNetworkRoute() {
|
||||||
console.log(resp.result);
|
console.log(resp.result);
|
||||||
setNetworkState(resp.result as NetworkState);
|
setNetworkState(resp.result as NetworkState);
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send, setNetworkState]);
|
||||||
|
|
||||||
const handleRenewLease = useCallback(() => {
|
const handleRenewLease = useCallback(() => {
|
||||||
send("renewDHCPLease", {}, resp => {
|
send("renewDHCPLease", {}, resp => {
|
||||||
|
@ -131,278 +180,520 @@ export default function SettingsNetworkRoute() {
|
||||||
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
|
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterUnknown = useCallback((options: { value: string; label: string; }[]) => {
|
const handleHostnameChange = (value: string) => {
|
||||||
if (!networkSettingsLoaded) return options;
|
setNetworkSettings({ ...networkSettings, hostname: value });
|
||||||
return options.filter(option => option.value !== "unknown");
|
};
|
||||||
}, [networkSettingsLoaded]);
|
|
||||||
|
const handleDomainChange = (value: string) => {
|
||||||
|
setNetworkSettings({ ...networkSettings, domain: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDomainOptionChange = (value: string) => {
|
||||||
|
setSelectedDomainOption(value);
|
||||||
|
if (value !== "custom") {
|
||||||
|
handleDomainChange(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomDomainChange = (value: string) => {
|
||||||
|
setCustomDomain(value);
|
||||||
|
handleDomainChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterUnknown = useCallback(
|
||||||
|
(options: { value: string; label: string }[]) => {
|
||||||
|
if (!networkSettingsLoaded) return options;
|
||||||
|
return options.filter(option => option.value !== "unknown");
|
||||||
|
},
|
||||||
|
[networkSettingsLoaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<SettingsPageHeader
|
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4">
|
||||||
title="Network"
|
<SettingsPageHeader
|
||||||
description="Configure your network settings"
|
title="Network"
|
||||||
/>
|
description="Configure your network settings"
|
||||||
<div className="space-y-4">
|
/>
|
||||||
<SettingsItem
|
<div className="space-y-4">
|
||||||
title="MAC Address"
|
<SettingsItem
|
||||||
description={<></>}
|
title="MAC Address"
|
||||||
>
|
description="Hardware identifier for the network interface"
|
||||||
<span className="select-auto font-mono text-xs text-slate-700 dark:text-slate-300">
|
>
|
||||||
{networkState?.mac_address}
|
<InputField
|
||||||
</span>
|
type="text"
|
||||||
</SettingsItem>
|
size="SM"
|
||||||
</div>
|
value={networkState?.mac_address}
|
||||||
<div className="space-y-4">
|
error={""}
|
||||||
<SettingsItem
|
disabled={true}
|
||||||
title="Hostname"
|
readOnly={true}
|
||||||
description={
|
className="dark:!text-opacity-60"
|
||||||
<>
|
/>
|
||||||
Hostname for the device
|
</SettingsItem>
|
||||||
<br />
|
</div>
|
||||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
<div className="space-y-4">
|
||||||
Leave blank for default
|
<SettingsItem
|
||||||
</span>
|
title="Hostname"
|
||||||
</>
|
description="Device identifier on the network. Blank for system default"
|
||||||
}
|
>
|
||||||
>
|
<div className="relative">
|
||||||
<InputField
|
<div>
|
||||||
type="text"
|
<InputField
|
||||||
placeholder="jetkvm"
|
size="SM"
|
||||||
value={networkSettings.hostname}
|
type="text"
|
||||||
error={""}
|
placeholder="jetkvm"
|
||||||
onChange={e => {
|
defaultValue={networkSettings.hostname}
|
||||||
setNetworkSettings({ ...networkSettings, hostname: e.target.value });
|
onChange={e => {
|
||||||
}}
|
handleHostnameChange(e.target.value);
|
||||||
disabled={!networkSettingsLoaded}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
|
||||||
title="Domain"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
Domain for the device
|
|
||||||
<br />
|
|
||||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
|
||||||
Leave blank to use DHCP provided domain, if there is no domain, use <span className="font-mono">local</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
placeholder="local"
|
|
||||||
value={networkSettings.domain}
|
|
||||||
error={""}
|
|
||||||
onChange={e => {
|
|
||||||
setNetworkSettings({ ...networkSettings, domain: e.target.value });
|
|
||||||
}}
|
|
||||||
disabled={!networkSettingsLoaded}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
|
||||||
title="IPv4 Mode"
|
|
||||||
description="Configure the IPv4 mode"
|
|
||||||
>
|
|
||||||
<SelectMenuBasic
|
|
||||||
size="SM"
|
|
||||||
value={networkSettings.ipv4_mode}
|
|
||||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
|
||||||
disabled={!networkSettingsLoaded}
|
|
||||||
options={filterUnknown([
|
|
||||||
{ value: "dhcp", label: "DHCP" },
|
|
||||||
// { value: "static", label: "Static" },
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
{networkState?.dhcp_lease && (
|
|
||||||
<GridCard>
|
|
||||||
<div className="flex items-start gap-x-4 p-4">
|
|
||||||
<div className="space-y-3 w-full">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
|
||||||
Current DHCP Lease
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
|
|
||||||
{networkState?.dhcp_lease?.ip && <li>IP: <strong>{networkState?.dhcp_lease?.ip}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.netmask && <li>Subnet: <strong>{networkState?.dhcp_lease?.netmask}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.broadcast && <li>Broadcast: <strong>{networkState?.dhcp_lease?.broadcast}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.ttl && <li>TTL: <strong>{networkState?.dhcp_lease?.ttl}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.mtu && <li>MTU: <strong>{networkState?.dhcp_lease?.mtu}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.hostname && <li>Hostname: <strong>{networkState?.dhcp_lease?.hostname}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.domain && <li>Domain: <strong>{networkState?.dhcp_lease?.domain}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.routers && <li>Gateway: <strong>{networkState?.dhcp_lease?.routers.join(", ")}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.dns && <li>DNS: <strong>{networkState?.dhcp_lease?.dns.join(", ")}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.ntp_servers && <li>NTP Servers: <strong>{networkState?.dhcp_lease?.ntp_servers.join(", ")}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.server_id && <li>Server ID: <strong>{networkState?.dhcp_lease?.server_id}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.bootp_next_server && <li>BootP Next Server: <strong>{networkState?.dhcp_lease?.bootp_next_server}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.bootp_server_name && <li>BootP Server Name: <strong>{networkState?.dhcp_lease?.bootp_server_name}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.bootp_file && <li>Boot File: <strong>{networkState?.dhcp_lease?.bootp_file}</strong></li>}
|
|
||||||
{networkState?.dhcp_lease?.lease_expiry && <li>
|
|
||||||
Lease Expiry: <LifeTimeLabel lifetime={`${networkState?.dhcp_lease?.lease_expiry}`} />
|
|
||||||
</li>}
|
|
||||||
{/* {JSON.stringify(networkState?.dhcp_lease)} */}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr className="block w-full dark:border-slate-600" />
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
size="SM"
|
|
||||||
theme="danger"
|
|
||||||
text="Renew lease"
|
|
||||||
onClick={() => {
|
|
||||||
handleRenewLease();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</SettingsItem>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem
|
<div className="space-y-4">
|
||||||
title="IPv6 Mode"
|
<SettingsItem
|
||||||
description="Configure the IPv6 mode"
|
title="Domain"
|
||||||
>
|
description="Network domain suffix for the device"
|
||||||
<SelectMenuBasic
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={selectedDomainOption}
|
||||||
|
onChange={e => handleDomainOptionChange(e.target.value)}
|
||||||
|
options={[
|
||||||
|
{ value: "dhcp", label: "DHCP provided" },
|
||||||
|
{ value: "local", label: ".local" },
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
{selectedDomainOption === "custom" && (
|
||||||
|
<div className="flex items-center justify-between gap-x-2">
|
||||||
|
<InputField
|
||||||
|
size="SM"
|
||||||
|
type="text"
|
||||||
|
placeholder="home"
|
||||||
|
value={customDomain}
|
||||||
|
onChange={e => setCustomDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text="Save Domain"
|
||||||
|
onClick={() => handleCustomDomainChange(customDomain)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title="mDNS"
|
||||||
|
description="Control mDNS (multicast DNS) operational mode"
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={networkSettings.mdns_mode}
|
||||||
|
onChange={e => handleMdnsModeChange(e.target.value)}
|
||||||
|
options={filterUnknown([
|
||||||
|
{ value: "disabled", label: "Disabled" },
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "ipv4_only", label: "IPv4 only" },
|
||||||
|
{ value: "ipv6_only", label: "IPv6 only" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title="Time synchronization"
|
||||||
|
description="Configure time synchronization settings"
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={networkSettings.time_sync_mode}
|
||||||
|
onChange={e => {
|
||||||
|
handleTimeSyncModeChange(e.target.value);
|
||||||
|
}}
|
||||||
|
options={filterUnknown([
|
||||||
|
{ value: "unknown", label: "..." },
|
||||||
|
// { value: "auto", label: "Auto" },
|
||||||
|
{ value: "ntp_only", label: "NTP only" },
|
||||||
|
{ value: "ntp_and_http", label: "NTP and HTTP" },
|
||||||
|
{ value: "http_only", label: "HTTP only" },
|
||||||
|
// { value: "custom", label: "Custom" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
value={networkSettings.ipv6_mode}
|
theme="primary"
|
||||||
onChange={e => handleIpv6ModeChange(e.target.value)}
|
disabled={firstNetworkSettings.current === networkSettings}
|
||||||
disabled={!networkSettingsLoaded}
|
text="Save Settings"
|
||||||
options={filterUnknown([
|
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||||
// { value: "disabled", label: "Disabled" },
|
|
||||||
{ value: "slaac", label: "SLAAC" },
|
|
||||||
// { value: "dhcpv6", label: "DHCPv6" },
|
|
||||||
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
|
|
||||||
// { value: "static", label: "Static" },
|
|
||||||
// { value: "link_local", label: "Link-local only" },
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</div>
|
||||||
{networkState?.ipv6_addresses && (
|
|
||||||
<GridCard>
|
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
<div className="flex items-start gap-x-4 p-4">
|
|
||||||
<div className="space-y-3 w-full">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode">
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={networkSettings.ipv4_mode}
|
||||||
|
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||||
|
options={filterUnknown([
|
||||||
|
{ value: "dhcp", label: "DHCP" },
|
||||||
|
// { value: "static", label: "Static" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
{networkState?.dhcp_lease && (
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||||
|
DHCP Lease
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-x-6 gap-y-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{networkState?.dhcp_lease?.ip && (
|
||||||
|
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
IP Address
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.ip}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.netmask && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Subnet Mask
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.netmask}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.dns && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
DNS Servers
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.dns.map(dns => (
|
||||||
|
<div key={dns}>{dns}</div>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.broadcast && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Broadcast
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.broadcast}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.domain && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Domain
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.domain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.ntp_servers &&
|
||||||
|
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
|
||||||
|
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
NTP Servers
|
||||||
|
</div>
|
||||||
|
<div className="shrink text-right text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.ntp_servers.map(server => (
|
||||||
|
<div key={server}>{server}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.hostname && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Hostname
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.hostname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{networkState?.dhcp_lease?.routers &&
|
||||||
|
networkState?.dhcp_lease?.routers.length > 0 && (
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Gateway
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.routers.map(router => (
|
||||||
|
<div key={router}>{router}</div>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.server_id && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
DHCP Server
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.server_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.lease_expiry && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Lease Expires
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<LifeTimeLabel
|
||||||
|
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.mtu && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
MTU
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.mtu}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.ttl && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
TTL
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.ttl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.bootp_next_server && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Boot Next Server
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.bootp_next_server}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.bootp_server_name && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Boot Server Name
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.bootp_server_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkState?.dhcp_lease?.bootp_file && (
|
||||||
|
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Boot File
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{networkState?.dhcp_lease?.bootp_file}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
className="text-red-500"
|
||||||
|
text="Renew DHCP Lease"
|
||||||
|
LeadingIcon={ArrowPathIcon}
|
||||||
|
onClick={() => setShowRenewLeaseConfirm(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={networkSettings.ipv6_mode}
|
||||||
|
onChange={e => handleIpv6ModeChange(e.target.value)}
|
||||||
|
options={filterUnknown([
|
||||||
|
// { value: "disabled", label: "Disabled" },
|
||||||
|
{ value: "slaac", label: "SLAAC" },
|
||||||
|
// { value: "dhcpv6", label: "DHCPv6" },
|
||||||
|
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
|
||||||
|
// { value: "static", label: "Static" },
|
||||||
|
// { value: "link_local", label: "Link-local only" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
{networkState?.ipv6_addresses && (
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||||
IPv6 Information
|
IPv6 Information
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
|
{networkState?.dhcp_lease?.ip && (
|
||||||
IPv6 Link-local
|
<div className="flex flex-col justify-between">
|
||||||
</h4>
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
Link-local
|
||||||
{networkState?.ipv6_link_local}
|
</span>
|
||||||
</p>
|
<span className="text-sm font-medium">
|
||||||
</div>
|
{networkState?.ipv6_link_local}
|
||||||
<div>
|
</span>
|
||||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
|
</div>
|
||||||
IPv6 Addresses
|
)}
|
||||||
</h4>
|
</div>
|
||||||
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
|
|
||||||
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => (
|
<div className="space-y-3 pt-2">
|
||||||
<li key={addr.address}>
|
{networkState?.ipv6_addresses &&
|
||||||
{addr.address}
|
networkState?.ipv6_addresses.length > 0 && (
|
||||||
{addr.valid_lifetime && <>
|
<div className="space-y-3">
|
||||||
<br />
|
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
|
||||||
- valid_lft: {" "}
|
{networkState.ipv6_addresses.map(addr => (
|
||||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
<div
|
||||||
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
|
key={addr.address}
|
||||||
</span>
|
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-slate-100/40 p-4 pl-4 dark:border-blue-500 dark:bg-slate-900"
|
||||||
</>}
|
>
|
||||||
{addr.preferred_lifetime && <>
|
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||||
<br />
|
<div className="col-span-2 flex flex-col justify-between">
|
||||||
- pref_lft: {" "}
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
<span className="text-xs text-slate-700 dark:text-slate-300">
|
Address
|
||||||
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
|
</span>
|
||||||
</span>
|
<span className="text-sm font-medium">
|
||||||
</>}
|
{addr.address}
|
||||||
</li>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
|
||||||
</div>
|
{addr.valid_lifetime && (
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Valid Lifetime
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{addr.valid_lifetime === "" ? (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">
|
||||||
|
N/A
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<LifeTimeLabel
|
||||||
|
lifetime={`${addr.valid_lifetime}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{addr.preferred_lifetime && (
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Preferred Lifetime
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{addr.preferred_lifetime === "" ? (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">
|
||||||
|
N/A
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<LifeTimeLabel
|
||||||
|
lifetime={`${addr.preferred_lifetime}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GridCard>
|
||||||
</GridCard>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="hidden space-y-4">
|
||||||
<div className="space-y-4 hidden">
|
<SettingsItem
|
||||||
<SettingsItem
|
title="LLDP"
|
||||||
title="LLDP"
|
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
|
||||||
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
|
>
|
||||||
>
|
<SelectMenuBasic
|
||||||
<SelectMenuBasic
|
size="SM"
|
||||||
size="SM"
|
value={networkSettings.lldp_mode}
|
||||||
value={networkSettings.lldp_mode}
|
onChange={e => handleLldpModeChange(e.target.value)}
|
||||||
onChange={e => handleLldpModeChange(e.target.value)}
|
options={filterUnknown([
|
||||||
disabled={!networkSettingsLoaded}
|
{ value: "disabled", label: "Disabled" },
|
||||||
options={filterUnknown([
|
{ value: "basic", label: "Basic" },
|
||||||
{ value: "disabled", label: "Disabled" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "basic", label: "Basic" },
|
])}
|
||||||
{ value: "all", label: "All" },
|
/>
|
||||||
])}
|
</SettingsItem>
|
||||||
/>
|
</div>
|
||||||
</SettingsItem>
|
</Fieldset>
|
||||||
</div>
|
<ConfirmDialog
|
||||||
<div className="space-y-4">
|
open={showRenewLeaseConfirm}
|
||||||
<SettingsItem
|
onClose={() => setShowRenewLeaseConfirm(false)}
|
||||||
title="mDNS"
|
title="Renew DHCP Lease"
|
||||||
description="Control mDNS (multicast DNS) operational mode"
|
description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process."
|
||||||
>
|
variant="danger"
|
||||||
<SelectMenuBasic
|
confirmText="Renew Lease"
|
||||||
size="SM"
|
onConfirm={() => {
|
||||||
value={networkSettings.mdns_mode}
|
handleRenewLease();
|
||||||
onChange={e => handleMdnsModeChange(e.target.value)}
|
setShowRenewLeaseConfirm(false);
|
||||||
disabled={!networkSettingsLoaded}
|
}}
|
||||||
options={filterUnknown([
|
/>
|
||||||
{ value: "disabled", label: "Disabled" },
|
</>
|
||||||
{ value: "auto", label: "Auto" },
|
|
||||||
{ value: "ipv4_only", label: "IPv4 only" },
|
|
||||||
{ value: "ipv6_only", label: "IPv6 only" },
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
|
||||||
title="Time synchronization"
|
|
||||||
description="Configure time synchronization settings"
|
|
||||||
>
|
|
||||||
<SelectMenuBasic
|
|
||||||
size="SM"
|
|
||||||
value={networkSettings.time_sync_mode}
|
|
||||||
onChange={e => handleTimeSyncModeChange(e.target.value)}
|
|
||||||
disabled={!networkSettingsLoaded}
|
|
||||||
options={filterUnknown([
|
|
||||||
{ value: "unknown", label: "..." },
|
|
||||||
// { value: "auto", label: "Auto" },
|
|
||||||
{ value: "ntp_only", label: "NTP only" },
|
|
||||||
{ value: "ntp_and_http", label: "NTP and HTTP" },
|
|
||||||
{ value: "http_only", label: "HTTP only" },
|
|
||||||
// { value: "custom", label: "Custom" },
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setNetworkSettingsRemote(networkSettings);
|
|
||||||
}}
|
|
||||||
size="SM"
|
|
||||||
theme="light"
|
|
||||||
text="Save Settings"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue