frontend #1

Merged
d3m0k1d merged 13 commits from frontend into backend 2026-04-03 22:47:30 +00:00
5 changed files with 756 additions and 1 deletions
Showing only changes of commit 57f12f792c - Show all commits
+7 -1
View File
@@ -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>
);
};
+224
View File
@@ -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>
);
};