frontend #1
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(yarn *)"
|
"Bash(yarn *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(type *)",
|
||||||
|
"Bash(dir)",
|
||||||
|
"Bash(move *)",
|
||||||
|
"Bash(findstr *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HomePage } from "@/pages/home.page";
|
|||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { ThemesPage } from "@/pages/themes.page";
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
@@ -21,6 +22,7 @@ export const Routing = () => {
|
|||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
<Route path="/themes" element={<ThemesPage />} />
|
||||||
|
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const DeployType = {
|
||||||
|
Docker: "docker",
|
||||||
|
Binary: "binary",
|
||||||
|
Deploy: "deploy",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const AuthMethod = {
|
||||||
|
Key: "key",
|
||||||
|
Password: "password",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface ExtraField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHAgentConfig {
|
||||||
|
user: string;
|
||||||
|
ip: string;
|
||||||
|
authMethod: string;
|
||||||
|
sshKey?: string;
|
||||||
|
password?: string;
|
||||||
|
extraFields: ExtraField[];
|
||||||
|
deployType: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
FiServer,
|
||||||
|
FiGlobe,
|
||||||
|
FiKey,
|
||||||
|
FiLock,
|
||||||
|
FiPlus,
|
||||||
|
FiTrash2,
|
||||||
|
FiSettings,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { SiDocker } from "react-icons/si";
|
||||||
|
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||||
|
|
||||||
|
type DeployType = "docker" | "binary" | "deploy";
|
||||||
|
type AuthMethod = "key" | "password";
|
||||||
|
|
||||||
|
interface ExtraField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHAgentConfig {
|
||||||
|
user: string;
|
||||||
|
ip: string;
|
||||||
|
authMethod: AuthMethod;
|
||||||
|
sshKey?: string;
|
||||||
|
password?: string;
|
||||||
|
extraFields: ExtraField[];
|
||||||
|
deployType: DeployType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHAgentFormProps {
|
||||||
|
index: number;
|
||||||
|
config: SSHAgentConfig;
|
||||||
|
onChange: (index: number, config: SSHAgentConfig) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPLOY_OPTIONS: {
|
||||||
|
value: DeployType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{ value: "docker", label: "Docker", icon: <SiDocker /> },
|
||||||
|
{ value: "binary", label: "Binary", icon: <FiPackage /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputBaseStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
transition: "border-color 0.2s, box-shadow 0.2s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "8px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
||||||
|
index,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
canRemove,
|
||||||
|
}) => {
|
||||||
|
const handleChange = (field: keyof SSHAgentConfig, value: unknown) => {
|
||||||
|
onChange(index, { ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtraFieldChange = (
|
||||||
|
fieldIndex: number,
|
||||||
|
field: keyof ExtraField,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newExtraFields = [...config.extraFields];
|
||||||
|
newExtraFields[fieldIndex] = {
|
||||||
|
...newExtraFields[fieldIndex],
|
||||||
|
[field]: value,
|
||||||
|
};
|
||||||
|
handleChange("extraFields", newExtraFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExtraField = () => {
|
||||||
|
handleChange("extraFields", [
|
||||||
|
...config.extraFields,
|
||||||
|
{ key: "", value: "" },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExtraField = (fieldIndex: number) => {
|
||||||
|
const newExtraFields = config.extraFields.filter(
|
||||||
|
(_, i) => i !== fieldIndex,
|
||||||
|
);
|
||||||
|
handleChange("extraFields", newExtraFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = (
|
||||||
|
e: React.FocusEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (
|
||||||
|
e: React.FocusEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
padding: "24px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "24px",
|
||||||
|
paddingBottom: "16px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiServer style={{ color: "var(--accent)", fontSize: "20px" }} />
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SSH сервер #{index + 1}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--error-bg)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
border: "1px solid var(--error-border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-text)";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-bg)";
|
||||||
|
e.currentTarget.style.color = "var(--error-text)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "20px" }}>
|
||||||
|
{/* User и IP */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiServer size={14} />
|
||||||
|
Пользователь *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.user}
|
||||||
|
onChange={(e) => handleChange("user", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiGlobe size={14} />
|
||||||
|
IP адрес *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.ip}
|
||||||
|
onChange={(e) => handleChange("ip", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Метод аутентификации */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<FiKey size={14} />
|
||||||
|
Метод аутентификации *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
{(["key", "password"] as const).map((method) => (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChange("authMethod", method)}
|
||||||
|
className="flex-1 py-2.5 px-4 rounded-lg border transition-all font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
config.authMethod === method
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--input-bg)",
|
||||||
|
color:
|
||||||
|
config.authMethod === method
|
||||||
|
? "var(--accent-text)"
|
||||||
|
: "var(--text-primary)",
|
||||||
|
borderColor:
|
||||||
|
config.authMethod === method
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{method === "key" ? "SSH ключ" : "Пароль"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Key или Password */}
|
||||||
|
{config.authMethod === "key" ? (
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiKey size={14} />
|
||||||
|
SSH ключ *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.sshKey || ""}
|
||||||
|
onChange={(e) => handleChange("sshKey", e.target.value)}
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
...inputBaseStyle,
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, monospace",
|
||||||
|
resize: "vertical",
|
||||||
|
}}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiLock size={14} />
|
||||||
|
Пароль *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={config.password || ""}
|
||||||
|
onChange={(e) => handleChange("password", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дополнительные поля */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ ...labelStyle, marginBottom: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiSettings size={14} />
|
||||||
|
Дополнительные параметры
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addExtraField}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = "0.85";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = "1";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiPlus size={14} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.extraFields.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="text-center py-6 rounded-lg border border-dashed"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSettings
|
||||||
|
size={20}
|
||||||
|
style={{ margin: "0 auto 8px", opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
<p style={{ margin: 0, fontSize: "13px" }}>
|
||||||
|
Нет дополнительных параметров
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.extraFields.map((extra, fieldIndex) => (
|
||||||
|
<div
|
||||||
|
key={fieldIndex}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr auto",
|
||||||
|
gap: "8px",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={extra.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleExtraFieldChange(fieldIndex, "key", e.target.value)
|
||||||
|
}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Параметр"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={extra.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleExtraFieldChange(fieldIndex, "value", e.target.value)
|
||||||
|
}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Значение"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExtraField(fieldIndex)}
|
||||||
|
className="flex items-center justify-center rounded-lg border transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--error-bg)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
borderColor: "var(--error-border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "18px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-text)";
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--error-bg)";
|
||||||
|
e.currentTarget.style.color = "var(--error-text)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тип развертывания */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<FiUploadCloud size={16} />
|
||||||
|
Тип развертывания *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DEPLOY_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChange("deployType", option.value)}
|
||||||
|
className="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border transition-all font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
config.deployType === option.value
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--input-bg)",
|
||||||
|
color:
|
||||||
|
config.deployType === option.value
|
||||||
|
? "var(--accent-text)"
|
||||||
|
: "var(--text-primary)",
|
||||||
|
borderColor:
|
||||||
|
config.deployType === option.value
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "18px" }}>{option.icon}</span>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
||||||
|
import { FiPlusCircle, FiSend } from "react-icons/fi";
|
||||||
|
|
||||||
|
interface SSHAgentConfig {
|
||||||
|
user: string;
|
||||||
|
ip: string;
|
||||||
|
authMethod: string;
|
||||||
|
sshKey?: string;
|
||||||
|
password?: string;
|
||||||
|
extraFields: { key: string; value: string }[];
|
||||||
|
deployType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
||||||
|
user: "",
|
||||||
|
ip: "",
|
||||||
|
authMethod: "key",
|
||||||
|
sshKey: "",
|
||||||
|
password: "",
|
||||||
|
extraFields: [],
|
||||||
|
deployType: "docker",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddAgentsPage: React.FC = () => {
|
||||||
|
const [agents, setAgents] = useState<SSHAgentConfig[]>([
|
||||||
|
createEmptyAgentConfig(),
|
||||||
|
]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAgentChange = (index: number, config: SSHAgentConfig) => {
|
||||||
|
const newAgents = [...agents];
|
||||||
|
newAgents[index] = config;
|
||||||
|
setAgents(newAgents);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentRemove = (index: number) => {
|
||||||
|
const newAgents = agents.filter((_, i) => i !== index);
|
||||||
|
setAgents(newAgents);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAgent = () => {
|
||||||
|
setAgents([...agents, createEmptyAgentConfig()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
const isValid = agents.every((agent) => {
|
||||||
|
if (!agent.user || !agent.ip) return false;
|
||||||
|
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
||||||
|
if (agent.authMethod === "password" && !agent.password) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
setSubmitError("Пожалуйста, заполните все обязательные поля");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitMessage(null);
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Реальный API вызов для развертывания агентов
|
||||||
|
console.log("Deploying agents:", agents);
|
||||||
|
|
||||||
|
// Имитация задержки API
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
setSubmitMessage(
|
||||||
|
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
|
||||||
|
);
|
||||||
|
setAgents([createEmptyAgentConfig()]);
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError("Ошибка при развертывании на серверах");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen py-8 px-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
|
>
|
||||||
|
<FiSend className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold mb-1"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Развертывание агентов по SSH
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
||||||
|
Настройте SSH-серверы и разверните агенты
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Agent Forms */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{agents.map((agent, index) => (
|
||||||
|
<SSHAgentForm
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
config={agent}
|
||||||
|
onChange={handleAgentChange}
|
||||||
|
onRemove={handleAgentRemove}
|
||||||
|
canRemove={agents.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Agent Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddAgent}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--accent)",
|
||||||
|
fontSize: "15px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--accent)";
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--accent)10";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiPlusCircle size={18} />
|
||||||
|
Добавить ещё один сервер
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{submitMessage && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--success-bg)",
|
||||||
|
borderColor: "var(--success-border)",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--error-bg)",
|
||||||
|
borderColor: "var(--error-border)",
|
||||||
|
color: "var(--error-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSubmitting
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
boxShadow: isSubmitting
|
||||||
|
? "none"
|
||||||
|
: "0 4px 14px var(--shadow-color)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--button-primary-hover)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isSubmitting
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--button-primary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
Подключение к серверам...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FiSend size={18} />
|
||||||
|
Развернуть на {agents.length} сервер(ах)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user