524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
import React from "react";
|
||
import {
|
||
FiServer,
|
||
FiGlobe,
|
||
FiKey,
|
||
FiLock,
|
||
FiPlus,
|
||
FiTrash2,
|
||
FiSettings,
|
||
FiLink,
|
||
} 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;
|
||
port: number;
|
||
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 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>
|
||
<label style={labelStyle}>
|
||
<span
|
||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||
>
|
||
<FiLink size={14} />
|
||
Порт *
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={config.port}
|
||
onChange={(e) =>
|
||
handleChange("port", parseInt(e.target.value) || 22)
|
||
}
|
||
required
|
||
min={1}
|
||
max={65535}
|
||
style={inputBaseStyle}
|
||
onFocus={handleFocus}
|
||
onBlur={handleBlur}
|
||
placeholder="22"
|
||
/>
|
||
</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",
|
||
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>
|
||
);
|
||
};
|