This commit is contained in:
@ -1,3 +1,4 @@
|
|||||||
REACT_APP_API_AUTH=http://127.0.0.1:8000
|
REACT_APP_API_AUTH=http://127.0.0.1:8000
|
||||||
REACT_APP_API_BLOG=http://127.0.0.1:8800
|
REACT_APP_API_BLOG=http://127.0.0.1:8800
|
||||||
REACT_APP_API_TODO=http://127.0.0.1:8880
|
REACT_APP_API_TODO=http://127.0.0.1:8880
|
||||||
|
REACT_APP_API_ANSIBLE=http://127.0.0.1:8888
|
@ -1,3 +1,4 @@
|
|||||||
REACT_APP_API_AUTH=https://www.icurfer.com
|
REACT_APP_API_AUTH=https://www.icurfer.com
|
||||||
REACT_APP_API_BLOG=https://www.icurfer.com
|
REACT_APP_API_BLOG=https://www.icurfer.com
|
||||||
REACT_APP_API_TODO=https://www.icurfer.com
|
REACT_APP_API_TODO=https://www.icurfer.com
|
||||||
|
REACT_APP_API_ANSIBLE=https://www.icurfer.com
|
@ -13,6 +13,7 @@ import Login from "./pages/Login";
|
|||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Profile from "./pages/Profile";
|
import Profile from "./pages/Profile";
|
||||||
import TaskPage from "./pages/TaskPage";
|
import TaskPage from "./pages/TaskPage";
|
||||||
|
import AnsiblePage from "./pages/Ansible";
|
||||||
|
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ function App() {
|
|||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/tasks" element={<TaskPage />} />
|
<Route path="/tasks" element={<TaskPage />} />
|
||||||
|
<Route path="/ansible" element={<AnsiblePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
12
src/api/ansibleApi.js
Normal file
12
src/api/ansibleApi.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// src/api/todoApi.js
|
||||||
|
import axios from 'axios';
|
||||||
|
import { attachAuthInterceptors } from './attachInterceptors';
|
||||||
|
|
||||||
|
const ansibleApi = axios.create({
|
||||||
|
baseURL: process.env.REACT_APP_API_ANSIBLE,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
attachAuthInterceptors(ansibleApi);
|
||||||
|
|
||||||
|
export default ansibleApi;
|
135
src/components/Ansible/AnsibleDetail.js
Normal file
135
src/components/Ansible/AnsibleDetail.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const AnsibleDetail = ({ task, onClose, onRun, onDelete, onEdit }) => {
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
playbook_content: "",
|
||||||
|
inventory_content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// task가 변경될 때 form 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (task) {
|
||||||
|
setForm({
|
||||||
|
name: task.name || "",
|
||||||
|
playbook_content: task.playbook_content || "",
|
||||||
|
inventory_content: task.inventory_content || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!form.name.trim()) return alert("이름을 입력해주세요.");
|
||||||
|
onEdit(task.id, form); // 부모에게 수정 요청
|
||||||
|
setEditMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 right-0 w-full sm:w-1/2 md:w-1/3 h-full bg-white shadow-lg p-6 overflow-y-auto z-50">
|
||||||
|
<button onClick={onClose} className="text-gray-600 hover:text-black float-right">
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-blue-600 mb-4">
|
||||||
|
{editMode ? "작업 수정" : task.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="font-semibold">이름</label>
|
||||||
|
{editMode ? (
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full border px-2 py-1 rounded mt-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>{task.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playbook */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="font-semibold">Playbook</label>
|
||||||
|
{editMode ? (
|
||||||
|
<textarea
|
||||||
|
name="playbook_content"
|
||||||
|
value={form.playbook_content}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={6}
|
||||||
|
className="w-full border px-2 py-1 rounded mt-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-gray-100 p-2 rounded whitespace-pre-wrap">
|
||||||
|
{task.playbook_content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="font-semibold">Inventory</label>
|
||||||
|
{editMode ? (
|
||||||
|
<textarea
|
||||||
|
name="inventory_content"
|
||||||
|
value={form.inventory_content}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={4}
|
||||||
|
className="w-full border px-2 py-1 rounded mt-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-gray-100 p-2 rounded whitespace-pre-wrap">
|
||||||
|
{task.inventory_content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
||||||
|
💾 저장
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditMode(false)} className="bg-gray-400 text-white px-4 py-2 rounded">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setEditMode(true)} className="bg-yellow-400 text-white px-4 py-2 rounded hover:bg-yellow-500">
|
||||||
|
✏ 수정
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onRun(task.id)} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
|
||||||
|
▶ 실행
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(task.id)} className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600">
|
||||||
|
🗑 삭제
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
{task.output && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<strong>실행 결과:</strong>
|
||||||
|
<pre className="bg-black text-green-400 p-3 rounded mt-1 text-sm whitespace-pre-wrap overflow-auto max-h-96">
|
||||||
|
{task.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnsibleDetail;
|
69
src/components/Ansible/AnsibleForm.js
Normal file
69
src/components/Ansible/AnsibleForm.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const AnsibleForm = ({ onCreate, onCancel }) => {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
playbook_content: "",
|
||||||
|
inventory_content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim()) return alert("이름을 입력하세요.");
|
||||||
|
onCreate(form);
|
||||||
|
setForm({ name: "", playbook_content: "", inventory_content: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white p-6 rounded shadow mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-[#3B82F6]">📝 새 작업 생성</h2>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-semibold mb-1">이름</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full border px-3 py-2 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-semibold mb-1">Playbook 내용</label>
|
||||||
|
<textarea
|
||||||
|
name="playbook_content"
|
||||||
|
value={form.playbook_content}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={6}
|
||||||
|
className="w-full border px-3 py-2 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-semibold mb-1">Inventory 내용</label>
|
||||||
|
<textarea
|
||||||
|
name="inventory_content"
|
||||||
|
value={form.inventory_content}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={4}
|
||||||
|
className="w-full border px-3 py-2 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
||||||
|
등록
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className="bg-gray-400 text-white px-4 py-2 rounded">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnsibleForm;
|
14
src/components/Ansible/AnsibleItem.js
Normal file
14
src/components/Ansible/AnsibleItem.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const AnsibleItem = ({ job, onClick }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-4 border rounded shadow-sm bg-white mb-2 cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={() => onClick(job)}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-gray-500">ID: {job.id}</div>
|
||||||
|
<div className="text-lg font-bold">{job.name}</div>
|
||||||
|
<div className="text-blue-600 font-medium">Status: {job.status}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnsibleItem;
|
16
src/components/Ansible/AnsibleList.js
Normal file
16
src/components/Ansible/AnsibleList.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import AnsibleItem from "./AnsibleItem";
|
||||||
|
|
||||||
|
const AnsibleList = ({ jobs, onItemClick }) => {
|
||||||
|
if (jobs.length === 0) return <p>등록된 작업이 없습니다.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<AnsibleItem key={job.id} job={job} onClick={onItemClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnsibleList;
|
@ -6,8 +6,17 @@ const Navbar = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isLoggedIn, logout, user } = useAuth();
|
const { isLoggedIn, logout, user } = useAuth();
|
||||||
|
|
||||||
// const menuItems = ["home", "about", "posts", "paas", "infra", "tasks"];
|
// ✅ 로그인 상태에 따라 메뉴 구성
|
||||||
const menuItems = ["home", "about", "posts", "tasks"];
|
const menuItems = [
|
||||||
|
{ name: "home", path: "/" },
|
||||||
|
{ name: "about", path: "/about" },
|
||||||
|
{ name: "posts", path: "/posts" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
menuItems.push({ name: "tasks", path: "/tasks" });
|
||||||
|
menuItems.push({ name: "ansible", path: "/ansible" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed w-full z-50 bg-white/90 backdrop-blur-md shadow-sm">
|
<nav className="fixed w-full z-50 bg-white/90 backdrop-blur-md shadow-sm">
|
||||||
@ -20,19 +29,18 @@ const Navbar = () => {
|
|||||||
<div className="hidden md:flex items-center space-x-8">
|
<div className="hidden md:flex items-center space-x-8">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item}
|
key={item.name}
|
||||||
to={item === "home" ? "/" : `/${item}`}
|
to={item.path}
|
||||||
className="text-gray-600 hover:text-[#D4AF37]"
|
className="text-gray-600 hover:text-[#D4AF37]"
|
||||||
>
|
>
|
||||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
{item.name.charAt(0).toUpperCase() + item.name.slice(1)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isLoggedIn && (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
// className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-[#8b0000]"
|
|
||||||
className="bg-[#8b0000] text-white px-4 py-2 rounded hover:bg-gray-300"
|
className="bg-[#8b0000] text-white px-4 py-2 rounded hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
내 정보 {user?.grade && `(${user.grade})`}
|
내 정보 {user?.grade && `(${user.grade})`}
|
||||||
@ -47,9 +55,7 @@ const Navbar = () => {
|
|||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!isLoggedIn && (
|
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
|
124
src/pages/Ansible.js
Normal file
124
src/pages/Ansible.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import AnsibleList from "../components/Ansible/AnsibleList";
|
||||||
|
import AnsibleDetail from "../components/Ansible/AnsibleDetail";
|
||||||
|
import AnsibleForm from "../components/Ansible/AnsibleForm";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const AnsiblePage = () => {
|
||||||
|
const [jobs, setJobs] = useState([]);
|
||||||
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const fetchJobs = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access");
|
||||||
|
const res = await axios.get(`${process.env.REACT_APP_API_ANSIBLE}/api/ansible/tasks/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
setJobs(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 작업 목록 조회 실패", err);
|
||||||
|
alert("작업 목록을 불러오는 데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (form) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access");
|
||||||
|
await axios.post(`${process.env.REACT_APP_API_ANSIBLE}/api/ansible/tasks/`, form, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
alert("✅ 작업이 생성되었습니다.");
|
||||||
|
setShowForm(false);
|
||||||
|
fetchJobs();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 작업 생성 실패", err);
|
||||||
|
alert("작업 생성에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRun = async (id) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access");
|
||||||
|
await axios.post(`${process.env.REACT_APP_API_ANSIBLE}/api/ansible/tasks/${id}/run/`, {}, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
alert("✅ 작업 실행 요청 완료");
|
||||||
|
fetchJobs();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 작업 실행 실패", err);
|
||||||
|
alert("작업 실행에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!window.confirm("정말로 삭제하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access");
|
||||||
|
await axios.delete(`${process.env.REACT_APP_API_ANSIBLE}/api/ansible/tasks/${id}/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
alert("✅ 삭제 완료");
|
||||||
|
fetchJobs();
|
||||||
|
setSelectedJob(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 삭제 실패", err);
|
||||||
|
alert("삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async (id, updatedData) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access");
|
||||||
|
await axios.put(
|
||||||
|
`${process.env.REACT_APP_API_ANSIBLE}/api/ansible/tasks/${id}/`,
|
||||||
|
updatedData,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
alert("✅ 작업 수정 완료");
|
||||||
|
fetchJobs();
|
||||||
|
setSelectedJob(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ 작업 수정 실패", err);
|
||||||
|
alert("작업 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 py-10 px-6">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* 상단 버튼 */}
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h1 className="text-2xl font-bold text-[#3B82F6]">Ansible 작업 목록</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{showForm ? "닫기" : "➕ 작업 생성"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 생성 폼 */}
|
||||||
|
{showForm && <AnsibleForm onCreate={handleCreate} onCancel={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{/* 작업 리스트 */}
|
||||||
|
<AnsibleList jobs={jobs} onItemClick={setSelectedJob} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세창 Drawer */}
|
||||||
|
<AnsibleDetail
|
||||||
|
task={selectedJob}
|
||||||
|
onClose={() => setSelectedJob(null)}
|
||||||
|
onRun={handleRun}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnsiblePage;
|
Reference in New Issue
Block a user