diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..a14e64f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,16 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} export default function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { +}: { + children: React.ReactNode +}) { return ( - - {children} - + {children} - ); + ) } diff --git a/src/app/page.module.css b/src/app/page.module.css new file mode 100644 index 0000000..9ad21e1 --- /dev/null +++ b/src/app/page.module.css @@ -0,0 +1,39 @@ +.container { + min-height: 90vh; + display: flex; + flex-direction: column; +} + +.main { + flex: 1; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.buttonContainer { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + + +.actionButton { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + background-color: #228be6; + color: white; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.actionButton:hover { + background-color: #1c7ed6; +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index e68abe6..ede3aef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,38 @@ -import Image from "next/image"; +'use client'; + +import React from 'react'; +import Header from '@/components/common/Header'; +import Terminal from '@/components/terminal/Terminal'; +import LevelSelectModal from '@/components/learn/LevelSelectModal'; +import ContainerListModal from '@/components/learn/ContainerListModal'; +import CommandDictionaryModal from '@/components/learn/CommandDictionaryModal'; +import styles from './page.module.css'; export default function Home() { + const [isLevelModalOpen, setIsLevelModalOpen] = React.useState(false); + const [isContainerListModalOpen, setIsContainerListModalOpen] = React.useState(false); + const [isCommandDictModalOpen, setIsCommandDictModalOpen] = React.useState(false); + return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
+
+ + setIsLevelModalOpen(false)} + /> + setIsContainerListModalOpen(false)} + /> + setIsCommandDictModalOpen(false)} + /> - +
+
-
); } diff --git a/src/components/browser/NetworkBrowser.module.css b/src/components/browser/NetworkBrowser.module.css new file mode 100644 index 0000000..31775b7 --- /dev/null +++ b/src/components/browser/NetworkBrowser.module.css @@ -0,0 +1,52 @@ +.browserContainer { + padding: 2rem; + background: #f8f9fa; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.networkList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.networkCard { + background: white; + padding: 1.5rem; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease; +} + +.networkCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.networkName { + font-size: 1.25rem; + font-weight: 600; + color: #2d3748; + margin-bottom: 1rem; +} + +.networkDetails { + color: #4a5568; + font-size: 0.875rem; +} + +.networkDetail { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.detailLabel { + font-weight: 500; +} + +.detailValue { + color: #718096; +} \ No newline at end of file diff --git a/src/components/common/Header.module.css b/src/components/common/Header.module.css new file mode 100644 index 0000000..fb5c7bf --- /dev/null +++ b/src/components/common/Header.module.css @@ -0,0 +1,48 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background-color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.logo { + font-size: 1.5rem; + font-weight: bold; +} + +.logo a { + color: #228be6; + text-decoration: none; + transition: color 0.2s; +} + +.logo a:hover { + color: #1c7ed6; +} + +.nav { + display: flex; + gap: 2rem; + align-items: center; +} + +.navLink { + color: #495057; + text-decoration: none; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 4px; + transition: all 0.2s; +} + +.navLink:hover { + color: #228be6; + background-color: #e7f5ff; +} + +.active { + color: #228be6; + background-color: #e7f5ff; +} \ No newline at end of file diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx new file mode 100644 index 0000000..e16f5b7 --- /dev/null +++ b/src/components/common/Header.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import styles from './Header.module.css'; + +const Header: React.FC = () => { + const pathname = usePathname(); + + const isActive = (path: string) => pathname === path; + + return ( +
+
+ + DOCKERSIM + +
+ +
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/console/Console.module.css b/src/components/console/Console.module.css new file mode 100644 index 0000000..3a6d3a2 --- /dev/null +++ b/src/components/console/Console.module.css @@ -0,0 +1,59 @@ +.container { + display: flex; + gap: 1rem; + height: 90vh; + min-height: 90vh; +} + +.leftPanel { + width: 30%; + display: flex; + flex-direction: column; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; +} + +.rightPanel { + width: 50%; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + overflow-y: auto; +} + +.consoleOutput { + flex: 1; + padding: 1rem; + background-color: white; + color: #333; + font-family: 'Courier New', monospace; + font-size: 14px; + overflow-y: auto; + min-height: 90vh; +} + +.inputArea { + border-top: 1px solid #ddd; + padding: 1rem; + background-color: white; +} + +.inputForm { + display: flex; + align-items: center; +} + +.commandInput { + flex: 1; + border: none; + outline: none; + font-family: 'Courier New', monospace; + font-size: 14px; + margin-left: 0.5rem; + color: #333; +} + +.commandInput::placeholder { + color: #666; +} \ No newline at end of file diff --git a/src/components/console/Console.tsx b/src/components/console/Console.tsx new file mode 100644 index 0000000..1a308a4 --- /dev/null +++ b/src/components/console/Console.tsx @@ -0,0 +1,178 @@ +import { useState, useRef, useEffect } from 'react'; +import styles from './Console.module.css'; +import NetworkTabs from '../terminal/NetworkTabs'; + +interface DockerNetwork { + id: string; + name: string; + created: Date; + isActive: boolean; +} + +type CommandOutput = { + id: string; + command: string; + result: string; + timestamp: Date; + networkId?: string; +}; + +const Console = () => { + const [input, setInput] = useState(''); + const [history, setHistory] = useState([]); + const [networks, setNetworks] = useState([]); + const [activeNetwork, setActiveNetwork] = useState(null); + const inputRef = useRef(null); + const consoleRef = useRef(null); + + const handleCommand = (command: string) => { + let result = ''; + const timestamp = new Date(); + const commandId = Date.now().toString(); + + if (command === 'help') { + result = `사용 가능한 도커 명령어: +- docker network create <이름>: 새로운 네트워크 생성 +- docker network ls: 네트워크 목록 조회 +- docker network rm <이름>: 네트워크 삭제 +- docker pull <이미지>: 도커 이미지 다운로드 +- docker images: 이미지 목록 조회 +- docker run <이미지>: 컨테이너 실행 +- docker ps: 실행 중인 컨테이너 목록 +- docker stop <컨테이너>: 컨테이너 중지 +- clear: 콘솔 내용 지우기`; + } else if (command === 'clear') { + setHistory([]); + return; + } else if (command.startsWith('docker')) { + if (command.startsWith('docker network create')) { + const networkName = command.split(' ').pop() as string; + const newNetwork: DockerNetwork = { + id: commandId, + name: networkName, + created: timestamp, + isActive: true + }; + setNetworks(prev => [...prev, newNetwork]); + setActiveNetwork(newNetwork.id); + result = `네트워크 '${networkName}'가 생성되었습니다.`; + } else if (command === 'docker network ls') { + if (networks.length === 0) { + result = '생성된 네트워크가 없습니다.'; + } else { + result = 'NETWORK ID NAME CREATED\n' + + networks.map(net => + `${net.id.slice(0, 12)} ${net.name} ${net.created.toLocaleString()}` + ).join('\n'); + } + } else if (command.startsWith('docker network rm')) { + const networkName = command.split(' ').pop() as string; + const network = networks.find(n => n.name === networkName); + if (network) { + setNetworks(prev => prev.filter(n => n.name !== networkName)); + if (activeNetwork === network.id) { + setActiveNetwork(null); + } + result = `네트워크 '${networkName}'가 삭제되었습니다.`; + } else { + result = `네트워크 '${networkName}'를 찾을 수 없습니다.`; + } + } else if (command.startsWith('docker pull')) { + result = '이미지를 다운로드하는 중...'; + } else if (command === 'docker images') { + result = `REPOSITORY TAG IMAGE ID CREATED SIZE +ubuntu latest 1234567890ab 2 hours ago 72.8MB +nginx latest 0987654321cd 3 days ago 142MB`; + } else if (command.startsWith('docker run')) { + const containerName = command.split(' ').pop(); + result = `컨테이너 ${containerName}를 실행하는 중...`; + } else if (command === 'docker ps') { + result = `CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +abcdef123456 ubuntu "/bin/bash" 2 min Up 80/tcp my-container`; + } else if (command.startsWith('docker stop')) { + const containerName = command.split(' ').pop(); + result = `컨테이너 ${containerName}를 중지하는 중...`; + } else { + result = '지원하지 않는 도커 명령어입니다.'; + } + } else { + result = `명령어를 찾을 수 없습니다: ${command}\n도움말을 보려면 'help'를 입력하세요.`; + } + + const historyEntry: CommandOutput = { + id: commandId, + command, + result, + timestamp, + networkId: activeNetwork || undefined + }; + setHistory(prev => [...prev, historyEntry]); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim()) return; + handleCommand(input.trim()); + setInput(''); + }; + + const removeNetwork = (networkId: string) => { + const network = networks.find(n => n.id === networkId); + if (network) { + handleCommand(`docker network rm ${network.name}`); + } + }; + + useEffect(() => { + inputRef.current?.focus(); + if (consoleRef.current) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; + } + }, [history]); + + const filteredHistory = history.filter(entry => + !activeNetwork || entry.networkId === activeNetwork + ); + + return ( +
+
+ {/* 콘솔 출력 영역 */} +
+ {filteredHistory.map((entry, idx) => ( +
+
$ {entry.command}
+
{entry.result}
+
+ ))} +
+ + {/* 명령어 입력 영역 */} +
+
+ $ + setInput(e.target.value)} + className={styles.commandInput} + placeholder="명령어를 입력하세요..." + /> +
+
+
+ +
+ +
+
+ ); +}; + +export default Console; diff --git a/src/components/learn/CommandDictionaryModal.tsx b/src/components/learn/CommandDictionaryModal.tsx new file mode 100644 index 0000000..3738869 --- /dev/null +++ b/src/components/learn/CommandDictionaryModal.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import styles from './Modal.module.css'; + +interface Command { + id: string; + category: string; + command: string; + description: string; + example: string; +} + +const commands: Command[] = [ + { + id: '1', + category: '이미지', + command: 'docker pull', + description: '도커 이미지를 다운로드합니다.', + example: 'docker pull nginx:latest' + }, + { + id: '2', + category: '이미지', + command: 'docker images', + description: '로컬에 저장된 도커 이미지 목록을 확인합니다.', + example: 'docker images' + }, + { + id: '3', + category: '컨테이너', + command: 'docker run', + description: '새로운 컨테이너를 생성하고 실행합니다.', + example: 'docker run -d --name my-nginx -p 80:80 nginx' + }, + { + id: '4', + category: '컨테이너', + command: 'docker ps', + description: '실행 중인 컨테이너 목록을 확인합니다.', + example: 'docker ps' + } +]; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const CommandDictionaryModal: React.FC = ({ isOpen, onClose }) => { + const [selectedCategory, setSelectedCategory] = React.useState('전체'); + const categories = ['전체', ...new Set(commands.map(cmd => cmd.category))]; + + const filteredCommands = selectedCategory === '전체' + ? commands + : commands.filter(cmd => cmd.category === selectedCategory); + + if (!isOpen) return null; + + return ( +
+
+
+

명령어 사전

+ +
+
+
+ {categories.map(category => ( + + ))} +
+
+ {filteredCommands.map(cmd => ( +
+

{cmd.command}

+

{cmd.description}

+
+ 예시: + {cmd.example} +
+
+ ))} +
+
+
+
+ ); +}; + +export default CommandDictionaryModal; \ No newline at end of file diff --git a/src/components/learn/ContainerListModal.tsx b/src/components/learn/ContainerListModal.tsx new file mode 100644 index 0000000..b3f0f76 --- /dev/null +++ b/src/components/learn/ContainerListModal.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React from 'react'; +import styles from './Modal.module.css'; + +interface Container { + id: string; + name: string; + image: string; + status: 'running' | 'stopped'; + ports: string[]; + createdAt: Date; +} + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const ContainerListModal: React.FC = ({ isOpen, onClose }) => { + // 실제로는 상위 컴포넌트에서 props로 받아와야 합니다 + const containers: Container[] = [ + { + id: '1234567890ab', + name: 'nginx-server', + image: 'nginx:latest', + status: 'running', + ports: ['80:80'], + createdAt: new Date() + }, + { + id: 'abcdef123456', + name: 'mysql-db', + image: 'mysql:8', + status: 'stopped', + ports: ['3306:3306'], + createdAt: new Date() + } + ]; + + if (!isOpen) return null; + + return ( +
+
+
+

컨테이너 리스트

+ +
+
+
+ {containers.map(container => ( +
+
+

{container.name}

+ + {container.status} + +
+
+

이미지: {container.image}

+

ID: {container.id}

+

포트: {container.ports.join(', ')}

+

생성일: {container.createdAt.toLocaleString()}

+
+
+ {container.status === 'stopped' ? ( + + ) : ( + + )} + +
+
+ ))} +
+
+
+
+ ); +}; + +export default ContainerListModal; \ No newline at end of file diff --git a/src/components/learn/LevelModal.tsx b/src/components/learn/LevelModal.tsx new file mode 100644 index 0000000..fc716e4 --- /dev/null +++ b/src/components/learn/LevelModal.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react' +import Modal from '@/components/common/Modal' + +interface LevelModalProps { + show: boolean + onClose: () => void + onSelect: (level: number, step: number) => void +} + +const LevelModal: React.FC = ({ show, onClose, onSelect }) => { + const [selectedLevel, setSelectedLevel] = useState(null) + + if (!show) return null + + const levels = [ + { + level: 1, + title: '초급', + description: 'Docker의 기본 개념과 명령어를 학습합니다.', + steps: [ + '컨테이너 생성 및 실행', + '이미지 관리' + ] + }, + { + level: 2, + title: '중급', + description: '볼륨, 네트워크, 포트 매핑을 학습합니다.', + steps: [ + '볼륨 마운트', + '네트워크 연결', + '포트 매핑' + ] + }, + { + level: 3, + title: '고급', + description: 'Docker Compose와 멀티 컨테이너 애플리케이션을 학습합니다.', + steps: [ + 'Docker Compose 기초', + '멀티 컨테이너 구성', + '컨테이너 오케스트레이션' + ] + } + ] + + return ( + +
+ {levels.map((level) => ( +
+
setSelectedLevel(level.level)} + > +
+
+ {level.title} + Level {level.level} +
+

{level.description}

+ + {selectedLevel === level.level && ( +
+
학습 단계
+
+ {level.steps.map((step, index) => ( + + ))} +
+
+ )} +
+
+
+ ))} +
+
+ ) +} + +export default LevelModal \ No newline at end of file diff --git a/src/components/learn/LevelSelectModal.tsx b/src/components/learn/LevelSelectModal.tsx new file mode 100644 index 0000000..873530b --- /dev/null +++ b/src/components/learn/LevelSelectModal.tsx @@ -0,0 +1,110 @@ +'use client'; + +import React from 'react'; +import styles from './Modal.module.css'; + +interface Level { + id: number; + title: string; + description: string; + steps: { + id: number; + title: string; + description: string; + }[]; +} + +const levels: Level[] = [ + { + id: 1, + title: '도커 기초', + description: '도커의 기본 개념과 명령어를 배웁니다.', + steps: [ + { + id: 1, + title: '도커 이미지 다루기', + description: '도커 이미지를 검색하고 다운로드하는 방법을 배웁니다.' + }, + { + id: 2, + title: '컨테이너 실행하기', + description: '도커 컨테이너를 생성하고 실행하는 방법을 배웁니다.' + } + ] + }, + { + id: 2, + title: '도커 네트워크', + description: '도커 네트워크의 개념과 설정 방법을 배웁니다.', + steps: [ + { + id: 1, + title: '네트워크 생성', + description: '도커 네트워크를 생성하고 설정하는 방법을 배웁니다.' + }, + { + id: 2, + title: '컨테이너 연결', + description: '컨테이너를 네트워크에 연결하는 방법을 배웁니다.' + } + ] + } +]; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const LevelSelectModal: React.FC = ({ isOpen, onClose }) => { + const [selectedLevel, setSelectedLevel] = React.useState(null); + + if (!isOpen) return null; + + return ( +
+
+
+

학습 레벨 선택

+ +
+
+
+ {levels.map(level => ( +
setSelectedLevel(level)} + > +

{level.title}

+

{level.description}

+
+ ))} +
+ {selectedLevel && ( +
+

학습 단계

+ {selectedLevel.steps.map(step => ( +
+

{step.title}

+

{step.description}

+
+ ))} +
+ )} +
+
+ +
+
+
+ ); +}; + +export default LevelSelectModal; \ No newline at end of file diff --git a/src/components/learn/Modal.module.css b/src/components/learn/Modal.module.css new file mode 100644 index 0000000..1d766cf --- /dev/null +++ b/src/components/learn/Modal.module.css @@ -0,0 +1,239 @@ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: white; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.modalHeader { + padding: 1rem; + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modalHeader h2 { + margin: 0; + font-size: 1.5rem; + color: #343a40; +} + +.closeButton { + background: none; + border: none; + font-size: 1.5rem; + color: #868e96; + cursor: pointer; + padding: 0.5rem; +} + +.closeButton:hover { + color: #495057; +} + +.modalContent { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.modalFooter { + padding: 1rem; + border-top: 1px solid #e9ecef; + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +/* 레벨 선택 모달 스타일 */ +.levelList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.levelItem { + padding: 1rem; + border: 2px solid #e9ecef; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.levelItem:hover { + border-color: #228be6; + background-color: #f8f9fa; +} + +.levelItem.selected { + border-color: #228be6; + background-color: #e7f5ff; +} + +.stepList { + border-top: 1px solid #e9ecef; + padding-top: 1.5rem; +} + +.stepItem { + padding: 1rem; + border: 1px solid #e9ecef; + border-radius: 6px; + margin-bottom: 1rem; +} + +/* 컨테이너 리스트 모달 스타일 */ +.containerList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.containerItem { + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 1rem; +} + +.containerHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.status { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; +} + +.status.running { + background-color: #d3f9d8; + color: #2b8a3e; +} + +.status.stopped { + background-color: #ffe3e3; + color: #c92a2a; +} + +.containerInfo { + margin-bottom: 1rem; +} + +.containerInfo p { + margin: 0.5rem 0; + font-size: 0.875rem; +} + +.containerActions { + display: flex; + gap: 0.5rem; +} + +.startButton, .stopButton, .removeButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + font-weight: 500; + cursor: pointer; +} + +.startButton { + background-color: #37b24d; + color: white; +} + +.stopButton { + background-color: #f59f00; + color: white; +} + +.removeButton { + background-color: #fa5252; + color: white; +} + +/* 명령어 사전 모달 스타일 */ +.categoryList { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.categoryButton { + padding: 0.5rem 1rem; + border: 1px solid #e9ecef; + border-radius: 4px; + background: none; + cursor: pointer; + transition: all 0.2s; +} + +.categoryButton:hover { + border-color: #228be6; + color: #228be6; +} + +.categoryButton.selected { + background-color: #228be6; + color: white; + border-color: #228be6; +} + +.commandList { + display: grid; + gap: 1rem; +} + +.commandItem { + padding: 1rem; + border: 1px solid #e9ecef; + border-radius: 6px; +} + +.commandItem h3 { + margin: 0 0 0.5rem 0; + color: #228be6; +} + +.description { + margin: 0.5rem 0; + color: #495057; +} + +.example { + background-color: #f8f9fa; + padding: 1rem; + border-radius: 4px; + margin-top: 0.5rem; +} + +.example code { + display: block; + margin-top: 0.5rem; + font-family: monospace; + color: #495057; +} \ No newline at end of file diff --git a/src/components/modal/Modal.module.css b/src/components/modal/Modal.module.css new file mode 100644 index 0000000..6edae99 --- /dev/null +++ b/src/components/modal/Modal.module.css @@ -0,0 +1,39 @@ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modalContent { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.closeButton { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; +} + +.closeButton:hover { + color: #333; +} \ No newline at end of file diff --git a/src/components/network/NetworkBrowser.module.css b/src/components/network/NetworkBrowser.module.css new file mode 100644 index 0000000..64edab0 --- /dev/null +++ b/src/components/network/NetworkBrowser.module.css @@ -0,0 +1,149 @@ +.browserContainer { + width: 80%; + height: 85vh; + background-color: #ffffff; + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + z-index: 9999; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); +} + +.networkTabs { + padding: 1rem 1rem 0; + border-bottom: 1px solid #3977b4; + background-color: #ffffff; +} + +.networkTab { + padding: 0.5rem 1rem; + border: none; + background: none; + font-size: 1rem; + color: #495057; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.networkTab.active { + color: #228be6; + border-bottom-color: #228be6; +} + +.contentArea { + flex: 1; + padding: 1rem; + overflow-y: auto; + background-color: #228be6; +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + color: #868e96; + gap: 0.5rem; +} + +.emptyState code { + background-color: #f1f3f5; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-family: monospace; +} + +.hint { + font-size: 0.875rem; + color: #adb5bd; +} + +.containersGrid { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.node { + background-color: #f8f9fa; + border: 2px solid #228be6; + border-radius: 8px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.node:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + border-color: #339af0; +} + +.nodeIcon { + font-size: 2rem; + text-align: center; +} + +.nodeName { + font-size: 1.25rem; + font-weight: bold; + color: #1c7ed6; + text-align: center; +} + +.nodeImage { + font-size: 0.875rem; + color: #495057; + font-family: monospace; + background-color: #f1f3f5; + padding: 0.25rem 0.5rem; + border-radius: 4px; + text-align: center; + word-break: break-all; +} + +.nodeStatus { + font-size: 0.875rem; + color: #2b8a3e; + background-color: #d3f9d8; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-weight: 500; + text-align: center; + align-self: center; +} + +.nodeDetails { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #dee2e6; +} + +.detailsTitle { + font-size: 0.875rem; + color: #868e96; + margin-bottom: 0.5rem; +} + +.detailItem { + font-size: 0.875rem; + color: #495057; + font-family: monospace; + background-color: #f1f3f5; + padding: 0.25rem 0.5rem; + border-radius: 4px; + margin-bottom: 0.25rem; + word-break: break-all; +} + +.connection { + position: absolute; + background-color: #569cd6; + height: 2px; +} \ No newline at end of file diff --git a/src/components/network/NetworkBrowser.tsx b/src/components/network/NetworkBrowser.tsx new file mode 100644 index 0000000..1e8c367 --- /dev/null +++ b/src/components/network/NetworkBrowser.tsx @@ -0,0 +1,123 @@ +import React, { forwardRef, useImperativeHandle, useState } from 'react'; +import styles from './NetworkBrowser.module.css'; + +export interface NetworkBrowserHandle { + handleCommandExecution: (command: string) => void; +} + +interface NetworkBrowserProps { + networkId: string; +} + +interface ContainerNode { + id: number; + type: 'container'; + name: string; + image: string; + status: string; + ports?: string[]; + volumes?: string[]; +} + +const NetworkBrowser = forwardRef( + ({ networkId }, ref) => { + const [nodes, setNodes] = useState([]); + const [activeTab, setActiveTab] = useState('containers'); + + useImperativeHandle(ref, () => ({ + handleCommandExecution: (command: string) => { + if (command.startsWith('docker')) { + const parts = command.split(' '); + if (parts[1] === 'run') { + // 컨테이너 이름 추출 + const nameIndex = parts.indexOf('--name'); + const containerName = nameIndex !== -1 ? parts[nameIndex + 1] : 'unnamed'; + + // 이미지 이름 추출 + const imageName = parts[parts.length - 1]; + + // 포트 매핑 추출 + const ports: string[] = []; + const portIndex = parts.indexOf('-p'); + if (portIndex !== -1) { + ports.push(parts[portIndex + 1]); + } + + // 볼륨 마운트 추출 + const volumes: string[] = []; + const volumeIndex = parts.indexOf('-v'); + if (volumeIndex !== -1) { + volumes.push(parts[volumeIndex + 1]); + } + + // 새로운 컨테이너 노드 추가 + const newNode: ContainerNode = { + id: Date.now(), + type: 'container', + name: containerName, + image: imageName, + status: 'running', + ports, + volumes + }; + + setNodes(prev => [...prev, newNode]); + } + } + } + })); + + return ( +
+
+ +
+
+ {nodes.length === 0 ? ( +
+
컨테이너가 없습니다
+ docker run +
명령어를 사용하여 컨테이너를 생성하세요
+
+ ) : ( +
+ {nodes.map((node) => ( +
+
🐳
+
{node.name}
+
{node.image}
+
{node.status}
+ {node.ports && node.ports.length > 0 && ( +
+
포트
+ {node.ports.map((port, index) => ( +
{port}
+ ))} +
+ )} + {node.volumes && node.volumes.length > 0 && ( +
+
볼륨
+ {node.volumes.map((volume, index) => ( +
{volume}
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+ ); + } +); + +NetworkBrowser.displayName = 'NetworkBrowser'; + +export default NetworkBrowser; \ No newline at end of file diff --git a/src/components/terminal/NetworkTabs.module.css b/src/components/terminal/NetworkTabs.module.css new file mode 100644 index 0000000..60b8cbb --- /dev/null +++ b/src/components/terminal/NetworkTabs.module.css @@ -0,0 +1,100 @@ +.tabContainer { + display: flex; + flex-direction: row; + background-color: #f3f3f3; + border-bottom: 1px solid #ddd; + height: 80vh; + padding: 0; + align-items: flex-end; +} + +.tab { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background-color: #f3f3f3; + border: 1px solid #ddd; + border-bottom: none; + border-radius: 8px 8px 0 0; + cursor: pointer; + user-select: none; + height: 32px; + min-width: 120px; + max-width: 200px; + margin-right: -1px; + margin-bottom: -1px; + position: relative; + transition: background-color 0.2s; +} + +.tab:hover { + background-color: #e8e8e8; +} + +.tab.active { + background-color: #fff; + border-bottom: none; + z-index: 1; +} + +.tab span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + font-size: 12px; + color: #5f6368; +} + +.tab.active span { + color: #202124; +} + +.closeButton { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0.5; + padding: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-left: 8px; + border-radius: 50%; +} + +.closeButton:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.1); +} + +.networkContainer { + display: flex; + flex-direction: column; + height: 100%; + background-color: #fff; +} + +.contentContainer { + flex: 1; + background-color: #fff; + padding: 16px; +} + +.emptyState { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #5f6368; + font-size: 14px; +} + +.networkInfo { + height: 100%; +} \ No newline at end of file diff --git a/src/components/terminal/NetworkTabs.tsx b/src/components/terminal/NetworkTabs.tsx new file mode 100644 index 0000000..db65d09 --- /dev/null +++ b/src/components/terminal/NetworkTabs.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React from 'react'; +import styles from './NetworkTabs.module.css'; + +interface DockerNetwork { + id: string; + name: string; + created: Date; + isActive: boolean; +} + +interface NetworkTabsProps { + networks: DockerNetwork[]; + activeNetwork: string | null; + onNetworkSelect: (id: string) => void; + onNetworkRemove: (id: string) => void; +} + +const NetworkTabs: React.FC = ({ + networks, + activeNetwork, + onNetworkSelect, + onNetworkRemove +}) => { + if (networks.length === 0) return null; + + return ( +
+
+ {networks.map(network => ( +
onNetworkSelect(network.id)} + > + {network.name} + +
+ ))} +
+
+ {activeNetwork ? ( +
+ {/* 네트워크 정보가 있을 때의 컨텐츠 */} +
+ ) : ( +
+

네트워크를 선택하여 정보를 확인하세요.

+
+ )} +
+
+ ); +}; + +export default NetworkTabs; \ No newline at end of file diff --git a/src/components/terminal/Terminal.module.css b/src/components/terminal/Terminal.module.css new file mode 100644 index 0000000..360f265 --- /dev/null +++ b/src/components/terminal/Terminal.module.css @@ -0,0 +1,661 @@ +.terminalContainer { + display: flex; + height: calc(100vh - 64px); + padding: 1rem; + gap: 1rem; +} + +.terminalSection { + flex: 0 0 30%; + background: #1E1E1E; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.browserSection { + flex: 1; + background: white; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +} + +.rightSection { + flex: 0 0 200px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.browserHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + gap: 1rem; +} + +.browserTitle { + font-size: 1.2rem; + font-weight: 500; + color: #333; +} + +.buttonGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.actionButton { + width: 100%; + padding: 0.75rem 1rem; + border: none; + border-radius: 6px; + background-color: #333; + color: white; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + text-align: center; + min-width: 180px; +} + +.actionButton:hover { + background-color: #444; +} + +.networkContent { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.output { + flex: 1; + padding: 1rem; + color: white; + font-family: 'Courier New', monospace; + font-size: 14px; + overflow-y: auto; +} + +.inputForm { + border-top: 1px solid #333; + padding: 0.75rem 1rem; + background-color: #1E1E1E; +} + +.commandLine { + display: flex; + align-items: center; + background-color: #252526; + padding: 8px 12px; + border-radius: 4px; +} + +.prompt { + color: #569cd6; + margin-right: 8px; + font-weight: bold; +} + +.input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: white; + font-family: 'Courier New', monospace; + font-size: 14px; +} + +.input::placeholder { + color: #666; +} + +/* Chrome 스타일 탭 */ +.chromeToolbar { + background: #f1f3f5; + padding: 8px 4px 0; + display: flex; + align-items: flex-end; + min-height: 40px; + border-bottom: 1px solid #dee2e6; +} + +.networkTabs { + display: flex; + align-items: flex-end; + height: 100%; + gap: 0; +} + +.chromeTab { + height: 32px; + padding: 0 10px 0 16px; + background: #e9ecef; + display: flex; + align-items: center; + gap: 8px; + min-width: 140px; + max-width: 180px; + position: relative; + cursor: pointer; + font-size: 12px; + color: #495057; + border-radius: 8px 8px 0 0; + margin-right: -10px; + transition: all 0.15s; +} + +.chromeTab::before, +.chromeTab::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + bottom: 0; + background: transparent; +} + +.chromeTab::before { + left: -8px; + border-bottom-right-radius: 8px; + box-shadow: 4px 4px 0 4px #e9ecef; +} + +.chromeTab::after { + right: -8px; + border-bottom-left-radius: 8px; + box-shadow: -4px 4px 0 4px #e9ecef; +} + +.chromeTab:hover { + background: #f1f3f5; +} + +.chromeTab:hover::before { + box-shadow: 4px 4px 0 4px #f1f3f5; +} + +.chromeTab:hover::after { + box-shadow: -4px 4px 0 4px #f1f3f5; +} + +.chromeTab.activeTab { + background: white; + z-index: 2; +} + +.chromeTab.activeTab::before { + box-shadow: 4px 4px 0 4px white; +} + +.chromeTab.activeTab::after { + box-shadow: -4px 4px 0 4px white; +} + +.tabIcon { + font-size: 16px; + opacity: 0.7; +} + +.tabTitle { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tabClose { + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: none; + color: #868e96; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.8; + margin-right: -4px; +} + +.tabClose:hover { + background: #dee2e6; + color: #495057; +} + +.networkContainer { + flex: 1; + display: flex; + flex-direction: column; + padding: 1.5rem; + overflow-y: auto; +} + +.containersSection { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.volumesSection { + display: flex; + gap: 1.5rem; + padding: 1.5rem 0; + margin-top: auto; + flex-wrap: nowrap !important; + overflow-x: auto; + align-items: center; + min-height: 120px; + white-space: nowrap; +} + +.volumeCircle { + flex: 0 0 100px; + min-width: 100px; + width: 100px; + height: 100px; + border-radius: 50%; + background: #1e88e5; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + margin-right: 1.5rem; + animation: slideIn 0.3s ease forwards; + flex-shrink: 0; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.volumeCircle:last-child { + margin-right: 0; +} + +.volumeCircle:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.volumeName { + color: white; + font-weight: 500; + text-align: center; + padding: 0.5rem; + font-size: 0.875rem; + word-break: break-word; +} + +.containerCard { + background: rgb(255, 255, 255); + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.3s ease; +} + +.containerCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.containerTitle { + font-weight: 600; + color: #2d3436; + margin-bottom: 1rem; +} + +.containerPorts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.portBadge { + background: #e1f5fe; + color: #0288d1; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.85rem; +} + +.chromeModal { + background: white; + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.chromeHeader { + padding: 1rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.chromeClose { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; + padding: 0.25rem; + line-height: 1; +} + +.chromeClose:hover { + color: #333; +} + +.modalContent { + padding: 2rem; +} + +.modalFooter { + padding: 1rem; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +.networkContainer { + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem; +} + +.containersSection { + flex-grow: 1; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.volumesSection { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + padding: 1rem 0; +} + +.volumeCircle { + width: 100px; + height: 100px; + background: #0288d1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + position: relative; +} + +.volumeCircle:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.volumeName { + font-size: 0.875rem; + color: #ffffff; + text-align: center; + word-break: break-word; + padding: 0.5rem; +} + +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.actionButton { + padding: 0.5rem 1rem; + border-radius: 4px; + border: none; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s ease; +} + +.actionButton.start { + background-color: #48bb78; + color: white; +} + +.actionButton.stop { + background-color: #f56565; + color: white; +} + +.actionButton.remove { + background-color: #718096; + color: white; +} + +.actionButton:hover { + opacity: 0.9; +} + +/* 스크롤바 스타일 */ +.output::-webkit-scrollbar { + width: 8px; +} + +.output::-webkit-scrollbar-track { + background: #1e1e1e; +} + +.output::-webkit-scrollbar-thumb { + background: #424242; + border-radius: 4px; +} + +.output::-webkit-scrollbar-thumb:hover { + background: #4f4f4f; +} + +/* 업로드 바 */ +.uploadBar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 48px; + background: white; + border-top: 1px solid #dee2e6; + display: flex; + align-items: center; + padding: 0 1rem; + justify-content: space-between; + z-index: 1000; +} + +.uploadSection { + display: flex; + align-items: center; + gap: 1rem; +} + +.uploadButton { + padding: 6px 12px; + background: #228be6; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s; +} + +.uploadButton:hover { + background: #1c7ed6; +} + +.viewImagesButton { + padding: 6px 12px; + background: #f8f9fa; + color: #495057; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.viewImagesButton:hover { + background: #e9ecef; + border-color: #ced4da; +} + +/* 반응형 스타일 */ +@media (max-width: 1200px) { + .terminalContainer { + width: 100%; + } +} + +@media (max-width: 768px) { + .terminalContainer { + flex-direction: column; + } + + .terminalSection { + height: 40vh; + } + + .browserSection { + height: calc(60vh - 48px); + } +} + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modalContent { + background: white; + border-radius: 12px; + width: 500px; + max-width: 90%; + max-height: 90vh; + overflow-y: auto; +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #dee2e6; +} + +.modalTitle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.modalIcon { + font-size: 1.5rem; +} + +.modalBody { + padding: 1.5rem; +} + +.infoCard { + background: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; +} + +.statusBadge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; +} + +.statusBadge.running { + background: #d3f9d8; + color: #2b8a3e; +} + +.statusBadge.stopped { + background: #ffe3e3; + color: #c92a2a; +} + +.volumeBadge { + background: #e7f5ff; + color: #1c7ed6; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag { + background: #e7f5ff; + color: #1c7ed6; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} diff --git a/src/components/terminal/Terminal.tsx b/src/components/terminal/Terminal.tsx new file mode 100644 index 0000000..ec00960 --- /dev/null +++ b/src/components/terminal/Terminal.tsx @@ -0,0 +1,570 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import styles from './Terminal.module.css'; +import { parseDockerRunCommand } from '@/utils/dockerCommandParser'; +import LevelSelectModal from '@/components/learn/LevelSelectModal'; +import ContainerListModal from '@/components/learn/ContainerListModal'; +import CommandDictionaryModal from '@/components/learn/CommandDictionaryModal'; + +interface Port { + hostPort: string; + containerPort: string; +} + +interface Volume { + id: string; + name: string; + mountPath?: string; + hostPath?: string; + createdAt: Date; +} + +interface Container { + id: string; + name: string; + image: string; + ports: Array<{ + hostPort: string; + containerPort: string; + }>; + status: 'running' | 'stopped'; + createdAt: Date; + volume?: Volume; + network?: string; + lastCommand?: string; +} + +interface Network { + id: string; + name: string; + containers: Container[]; + isNew?: boolean; +} + +interface Image { + id: string; + name: string; + tag: string; + size: string; + created: Date; + isOfficial: boolean; +} + +interface Props { + containers?: Container[]; + networks?: Network[]; + images?: Image[]; + onNetworkCreate?: (networkName: string) => void; +} + +interface ModalProps { + onClose: () => void; +} + +const ContainerCard = ({ container, onClick }: { container: Container; onClick: () => void }) => ( +
+

{container.name}

+
+ {container.ports.map((port, index) => ( + + {port.hostPort}:{port.containerPort} + + ))} +
+ {container.volume && ( +
+
+
+ )} +
+); + +const NetworkView = ({ network }: { network: Network }) => { + const [selectedContainer, setSelectedContainer] = useState(null); + + return ( +
+
+ {network.containers.map((container) => ( + setSelectedContainer(container)} + /> + ))} +
+ {selectedContainer && ( + setSelectedContainer(null)} + /> + )} +
+ ); +}; + +const ContainerModal: React.FC<{ container: Container } & ModalProps> = ({ container, onClose }) => { + return ( +
+
e.stopPropagation()}> +
+
+
+ 📦 + {container.name} +
+
+ +
+
+
+
+
+ + {container.status === 'running' ? '실행 중' : '중지됨'} + +
+
+
+ + {container.image} +
+
+ + {container.id.slice(0, 12)} +
+
+ + {container.network || '없음'} +
+
+ + {container.createdAt.toLocaleString()} +
+ {container.ports.length > 0 && ( +
+ +
+ {container.ports.map((port, idx) => ( + + {port.hostPort}:{port.containerPort} + + ))} +
+
+ )} + {container.volume && ( +
+ +
+ {container.volume.name} + {container.volume.mountPath} +
+
+ )} +
+
+
+
+
+ + +
+
+
+ ); +}; + +const VolumeModal: React.FC<{ volume: Volume } & ModalProps> = ({ volume, onClose }) => { + return ( +
+
e.stopPropagation()}> +
+
+
+ 💾 + {volume.name} +
+
+ +
+
+
+
+
+
+ + {volume.name} +
+ {volume.mountPath && ( +
+ + {volume.mountPath} +
+ )} +
+ + {volume.createdAt.toLocaleString()} +
+
+
+
+
+
+
+ ); +}; + +const Terminal: React.FC = () => { + const [command, setCommand] = useState(''); + const [output, setOutput] = useState([]); + const [networks, setNetworks] = useState([]); + const [volumes, setVolumes] = useState([]); + const [activeNetwork, setActiveNetwork] = useState(null); + const [selectedContainer, setSelectedContainer] = useState(null); + const [selectedVolume, setSelectedVolume] = useState(null); + const [showImageDetails, setShowImageDetails] = useState(false); + const [isLevelModalOpen, setIsLevelModalOpen] = useState(false); + const [isContainerListModalOpen, setIsContainerListModalOpen] = useState(false); + const [isCommandDictModalOpen, setIsCommandDictModalOpen] = useState(false); + const fileInputRef = useRef(null); + const terminalRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!command.trim()) return; + + setOutput(prev => [...prev, `$ ${command}`]); + + if (command.startsWith('docker network create')) { + const networkName = command.split(' ')[3]; + if (networkName) { + const newNetwork: Network = { + id: Date.now().toString(), + name: networkName, + containers: [] + }; + setNetworks(prev => [...prev, newNetwork]); + setActiveNetwork(newNetwork.id); + setOutput(prev => [...prev, `네트워크 '${networkName}'가 생성되었습니다.`]); + } + } + + setCommand(''); + }; + + const handleCommandSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedCommand = command.trim(); + if (!trimmedCommand) return; + + setOutput(prev => [...prev, `$ ${trimmedCommand}`]); + + if (trimmedCommand.startsWith('docker')) { + if (trimmedCommand.startsWith('docker volume create')) { + const volumeName = trimmedCommand.split(' ')[3]; + if (volumeName) { + const newVolume: Volume = { + id: Date.now().toString(), + name: volumeName, + createdAt: new Date() + }; + + setVolumes(prev => [...prev, newVolume]); + setOutput(prev => [...prev, `볼륨 '${volumeName}'이 생성되었습니다.`]); + } + } + else if (trimmedCommand.startsWith('docker network create')) { + const networkName = trimmedCommand.split(' ')[3]; + if (networkName) { + const newNetwork: Network = { + id: Date.now().toString(), + name: networkName, + containers: [] + }; + + setNetworks(prev => [...prev, newNetwork]); + setActiveNetwork(newNetwork.id); + setOutput(prev => [...prev, `네트워크 '${networkName}'이 생성되었습니다.`]); + } + } + else if (trimmedCommand.startsWith('docker run')) { + const options = parseDockerRunCommand(trimmedCommand); + + if (options && activeNetwork) { + const portMappings = trimmedCommand.match(/-p\s+(\d+:\d+)/g) || []; + const ports = portMappings.map(mapping => { + const [host, container] = mapping.replace('-p', '').trim().split(':'); + return { hostPort: host, containerPort: container }; + }); + + const container: Container = { + id: Date.now().toString(), + name: options.name || `container_${Date.now().toString().slice(-6)}`, + image: options.image, + ports: ports, + status: 'running', + createdAt: new Date(), + network: activeNetwork + }; + + if (options.volumes && options.volumes.length > 0) { + const volumeOption = options.volumes[0]; + const volume = volumes.find(v => v.name === volumeOption.name); + if (volume) { + setVolumes(prev => prev.map(v => + v.id === volume.id + ? { ...v, mountPath: volumeOption.mountPath } + : v + )); + container.volume = volume; + } + } + + setNetworks(prev => prev.map(network => + network.id === activeNetwork + ? { ...network, containers: [...network.containers, container] } + : network + )); + + setOutput(prev => [ + ...prev, + `컨테이너 '${container.name}'이 생성되었습니다.`, + `이미지: ${container.image}`, + ...(container.ports.length > 0 + ? [`포트: ${container.ports.map(p => `${p.hostPort}:${p.containerPort}`).join(', ')}`] + : []), + ...(container.volume + ? [`볼륨: ${container.volume.name}`] + : []) + ]); + } else if (!activeNetwork) { + setOutput(prev => [...prev, '먼저 네트워크를 선택해주세요.']); + } + } + } + + setCommand(''); + + if (terminalRef.current) { + setTimeout(() => { + terminalRef.current!.scrollTop = terminalRef.current!.scrollHeight; + }, 100); + } + }; + + const handleTabClick = (tabId: string) => { + setActiveNetwork(tabId); + }; + + const handleTabClose = (tabId: string) => { + setNetworks(prev => prev.filter(network => network.id !== tabId)); + if (activeNetwork === tabId) { + setActiveNetwork(networks[0]?.id || null); + } + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // 여기에 파일 업로드 로직 추가 + setOutput(prev => [...prev, `이미지 '${file.name}'를 업로드하는 중...`]); + + // 실제 업로드 로직을 구현하세요 + setTimeout(() => { + setOutput(prev => [...prev, `이미지 '${file.name}'가 성공적으로 업로드되었습니다.`]); + }, 1000); + } + }; + + const getContainerLayoutClass = (containers: Container[]) => { + if (containers.length === 1) return styles.single; + if (containers.length === 2) return styles.double; + return ''; + }; + + return ( +
+
+
+ {output.map((line, index) => ( +
+ {line} +
+ ))} +
+
+
+ $ + setCommand(e.target.value)} + placeholder="명령어를 입력하세요..." + spellCheck={false} + autoComplete="off" + /> +
+
+
+ +
+
+
+ {networks.map(network => ( +
setActiveNetwork(network.id)} + > + + {network.name} + +
+ ))} +
+
+
+ {networks.map(network => ( +
+
+
+ {network.containers.map(container => ( +
+
setSelectedContainer(container)} + > +
{container.name}
+
+ {container.ports.map((port, idx) => ( + + {port.hostPort}:{port.containerPort} + + ))} +
+
+
+ ))} +
+
+ {volumes.map((volume, index) => ( +
setSelectedVolume(volume)} + > +
{volume.name}
+
+ ))} +
+
+
+ ))} +
+
+ +
+
+ + + +
+
+ + setIsLevelModalOpen(false)} + /> + setIsContainerListModalOpen(false)} + /> + setIsCommandDictModalOpen(false)} + /> + +
+
+ + + +
+
+ + {selectedContainer && ( + setSelectedContainer(null)} + /> + )} + + {selectedVolume && ( + setSelectedVolume(null)} + /> + )} +
+ ); +}; + +export default Terminal; \ No newline at end of file diff --git a/src/components/visualization/BrowserTab.tsx b/src/components/visualization/BrowserTab.tsx new file mode 100644 index 0000000..64cd30b --- /dev/null +++ b/src/components/visualization/BrowserTab.tsx @@ -0,0 +1,39 @@ +'use client'; + +import React from 'react'; +import styles from './BrowserTab.module.css'; + +interface Props { + name: string; + isActive: boolean; + onClick: () => void; + onClose: () => void; +} + +const BrowserTab: React.FC = ({ name, isActive, onClick, onClose }) => { + return ( +
+
+ + + +
+ {name} + +
+ ); +}; \ No newline at end of file diff --git a/src/components/visualization/NetworkVisualizer.module.css b/src/components/visualization/NetworkVisualizer.module.css new file mode 100644 index 0000000..03a6e1b --- /dev/null +++ b/src/components/visualization/NetworkVisualizer.module.css @@ -0,0 +1,110 @@ +.networkVisualizer { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + height: 100%; + overflow-y: auto; +} + +.networkCard { + background-color: #f8f9fa; + border: 2px solid #228be6; + border-radius: 8px; + padding: 1.5rem; +} + +.networkHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e9ecef; +} + +.networkHeader h3 { + margin: 0; + color: #228be6; + font-size: 1.25rem; +} + +.networkInfo { + color: #868e96; + font-size: 0.875rem; +} + +.networkContainers { + margin-top: 1rem; +} + +.emptyNetwork { + text-align: center; + color: #868e96; + padding: 2rem; + background-color: #f1f3f5; + border-radius: 4px; +} + +.containerGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; +} + +.containerCard { + background-color: white; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 1rem; + transition: all 0.2s; +} + +.containerCard:hover { + border-color: #228be6; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.containerHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.containerHeader h4 { + margin: 0; + color: #495057; + font-size: 1rem; +} + +.status { + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.status.running { + background-color: #d3f9d8; + color: #2b8a3e; +} + +.status.stopped { + background-color: #ffe3e3; + color: #c92a2a; +} + +.containerInfo { + font-size: 0.875rem; +} + +.containerInfo p { + margin: 0.25rem 0; + color: #495057; +} + +.containerInfo strong { + color: #343a40; + margin-right: 0.5rem; +} \ No newline at end of file diff --git a/src/components/visualization/NetworkVisualizer.tsx b/src/components/visualization/NetworkVisualizer.tsx new file mode 100644 index 0000000..2d32de3 --- /dev/null +++ b/src/components/visualization/NetworkVisualizer.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import styles from './NetworkVisualizer.module.css'; + +interface Container { + id: string; + name: string; + image: string; + ports: string[]; + status: 'running' | 'stopped'; + createdAt: Date; + network?: string; +} + +interface Network { + id: string; + name: string; + containers: string[]; + createdAt: Date; +} + +interface Props { + networks: Network[]; + containers: Container[]; +} + +const NetworkVisualizer: React.FC = ({ networks, containers }) => { + return ( +
+ {networks.map(network => ( +
+
+

{network.name}

+ + 생성일: {network.createdAt.toLocaleString()} + +
+
+ {network.containers.length === 0 ? ( +
+ 연결된 컨테이너가 없습니다 +
+ ) : ( +
+ {network.containers.map(containerName => { + const container = containers.find(c => c.name === containerName); + if (!container) return null; + + return ( +
+
+

{container.name}

+ + {container.status} + +
+
+

이미지: {container.image}

+

포트: {container.ports.join(', ') || '없음'}

+
+
+ ); + })} +
+ )} +
+
+ ))} +
+ ); +}; + +export default NetworkVisualizer; \ No newline at end of file diff --git a/src/utils/dockerCommandParser.ts b/src/utils/dockerCommandParser.ts new file mode 100644 index 0000000..78f257a --- /dev/null +++ b/src/utils/dockerCommandParser.ts @@ -0,0 +1,114 @@ +interface ParsedPort { + host: string; + container: string; +} + +interface ParsedVolume { + name: string; + mountPath: string; +} + +interface ParsedContainer { + name?: string; + image: string; + ports: ParsedPort[]; + volumes: ParsedVolume[]; + networkId?: string; +} + +interface ParsedNetwork { + id: string; + name: string; + created: string; +} + +export function parseDockerNetworkCommand(command: string): ParsedNetwork | null { + try { + const parts = command.trim().split(/\s+/); + console.log('Network command parts:', parts); + + // docker network create [name] + if (parts.length >= 4 && + parts[0] === 'docker' && + parts[1] === 'network' && + parts[2] === 'create') { + const networkName = parts[3]; + console.log('Creating network with name:', networkName); + + return { + id: `network-${Date.now()}`, + name: networkName, + created: new Date().toISOString() + }; + } + + return null; + } catch (error) { + console.error('Error parsing network command:', error); + return null; + } +} + +export function parseDockerRunCommand(command: string): ParsedContainer | null { + const parts = command.split(' ').filter(part => part !== '\\'); + if (parts[0] !== 'docker' || parts[1] !== 'run') return null; + + const result: ParsedContainer = { + image: '', + ports: [], + volumes: [] + }; + + let i = 2; + while (i < parts.length) { + if (parts[i] === '-d' || parts[i] === '--detach') { + i++; + continue; + } + + if (parts[i] === '--name' && parts[i + 1]) { + result.name = parts[i + 1]; + i += 2; + continue; + } + + if ((parts[i] === '-p' || parts[i] === '--publish') && parts[i + 1]) { + const portMapping = parts[i + 1].split(':'); + if (portMapping.length === 2) { + result.ports.push({ + host: portMapping[0], + container: portMapping[1] + }); + } + i += 2; + continue; + } + + if ((parts[i] === '-v' || parts[i] === '--volume') && parts[i + 1]) { + const volumeMapping = parts[i + 1].split(':'); + if (volumeMapping.length === 2) { + result.volumes.push({ + name: volumeMapping[0], + mountPath: volumeMapping[1] + }); + } + i += 2; + continue; + } + + if (parts[i] === '--network' && parts[i + 1]) { + result.networkId = parts[i + 1]; + i += 2; + continue; + } + + if (!parts[i].startsWith('-')) { + result.image = parts[i]; + break; + } + + i++; + } + + return result.image ? result : null; +} \ No newline at end of file diff --git a/src/utils/dockerCommandParser.tsx b/src/utils/dockerCommandParser.tsx new file mode 100644 index 0000000..15b4551 --- /dev/null +++ b/src/utils/dockerCommandParser.tsx @@ -0,0 +1,46 @@ +interface DockerRunOptions { + name?: string; + ports: string[]; + image: string; + detach: boolean; +} + +export const parseDockerRunCommand = (command: string): DockerRunOptions | null => { + const parts = command.trim().split(' '); + + // docker run 명령어가 아닌 경우 null 반환 + if (parts[0] !== 'docker' || parts[1] !== 'run') { + return null; + } + + const options: DockerRunOptions = { + ports: [], + image: parts[parts.length - 1], + detach: false + }; + + // 옵션 파싱 + for (let i = 2; i < parts.length - 1; i++) { + switch (parts[i]) { + case '-d': + case '--detach': + options.detach = true; + break; + case '--name': + if (i + 1 < parts.length - 1) { + options.name = parts[i + 1]; + i++; + } + break; + case '-p': + case '--publish': + if (i + 1 < parts.length - 1) { + options.ports.push(parts[i + 1]); + i++; + } + break; + } + } + + return options; +}; \ No newline at end of file