(null);
22 |
23 | useEffect(() => {
24 | if (!terminalRef.current) return;
25 |
26 | const { terminal, clipboardProvider, fitAddon } = createTerminal(terminalRef.current);
27 | terminalInstanceRef.current = terminal;
28 | loadingRef.current = new LoadingTerminal(terminal);
29 | clipboardProviderRef.current = clipboardProvider;
30 |
31 | if (hostStatus === HOST_STATUS.STARTING) {
32 | loadingRef.current.hostSpinnerStart();
33 | terminal.options.disableStdin = true; // 입력 비활성화
34 | } else {
35 | terminal.write('~$ ');
36 | }
37 | fitAddon.fit();
38 |
39 | return () => terminal.dispose();
40 | // eslint-disable-next-line react-hooks/exhaustive-deps
41 | }, []);
42 |
43 | useEffect(() => {
44 | if (!terminalInstanceRef.current || !clipboardProviderRef.current) return;
45 |
46 | if (!loadingRef.current) return;
47 |
48 | if (hostStatus === HOST_STATUS.READY) {
49 | loadingRef.current.hostSpinnerStop();
50 | terminalInstanceRef.current.write('~$ ');
51 | terminalInstanceRef.current.options.disableStdin = false; // 입력 활성화
52 | terminalInstanceRef.current.onKey((event) => {
53 | if (hostStatus === HOST_STATUS.READY) {
54 | handleKeyInput(
55 | terminalInstanceRef.current as Terminal,
56 | event,
57 | clipboardProviderRef.current as BrowserClipboardProvider
58 | );
59 | }
60 | });
61 | terminalInstanceRef.current.focus();
62 | }
63 | }, [hostStatus]);
64 |
65 | return (
66 |
69 | );
70 | };
71 |
72 | export default XTerminal;
73 |
--------------------------------------------------------------------------------
/frontend/src/components/staticpages/DockerContainerLifeCyclePage.tsx:
--------------------------------------------------------------------------------
1 | import lifeCylceImage from '../../assets/containerLifeCycle.png';
2 |
3 | const DockerContainerLifeCyclePage = () => {
4 | return (
5 |
6 |
Container 생명주기에 대해서 알아볼까요?
7 |

13 |
14 |
15 |
Container의 상태
16 |
17 |
18 | 컨테이너는{' '}
19 |
20 | 생성, 실행, 중지 등 다양한 상태를 가지며, Docker 명령어를 통해
21 | 상태를 전환
22 |
23 | 할 수 있습니다.
24 |
25 |
26 |
27 |
1. Created
28 |
29 |
30 | 도커 이미지로부터 도커 컨테이너가 생성된 상태이며 컨테이너가 실행된
31 | 상태는 아닙니다.
32 |
33 |
34 |
35 |
2. Running
36 |
37 |
38 | docker start 또는 docker run 명령어로 시작된, 실행 중인 컨테이너입니다.
39 |
40 |
41 |
42 |
3. Stopped
43 |
44 |
45 | 컨테이너의 실행이 중지된 상태로 컨테이너의 모든 프로세스가 종료되고,
46 | 컨테이너가 더 이상 실행되지 않습니다.
47 |
48 |
49 |
50 |
4. Paused
51 |
52 |
컨테이너 내부의 모든 프로세스가 일시 중단된 상태입니다.
53 |
54 |
55 |
5. Deleted
56 |
57 |
Docker 컨테이너가 삭제된 상태입니다.
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default DockerContainerLifeCyclePage;
66 |
--------------------------------------------------------------------------------
/frontend/src/components/staticpages/DockerImagePage.tsx:
--------------------------------------------------------------------------------
1 | const WhatIsDockerImagePage = () => {
2 | return (
3 |
4 |
5 |
Docker Image란 무엇일까요?
6 |
7 |
8 | 이미지는{' '}
9 |
10 | 컨테이너 실행에 필요한 파일, 바이너리, 라이브러리, 설정 등을 모두 포함한
11 | 표준화된 패키지
12 |
13 | 입니다.
14 |
15 |
이미지에는 두 가지 중요한 원칙이 있습니다:
16 |
17 | -
18 |
1. 이미지는 불변성을 가집니다
19 |
20 | 이미지가 생성되면 수정할 수 없습니다. 새 이미지를 만들거나 그 위에
21 | 변경 사항을 추가할 수 있을 뿐입니다.
22 |
23 |
24 | -
25 |
26 | 2. 이미지는 레이어로 구성됩니다
27 |
28 |
29 | 각 레이어는 파일을 추가, 제거 또는 수정하는 일련의 파일 시스템 변경
30 | 사항을 나타냅니다.
31 |
32 |
33 |
34 |
35 | 예를 들어, Python 앱을 구축하는 경우 Python 이미지를 기반으로 시작하여
36 | 애플리케이션의 종속성을 설치하거나 코드를 추가하는 새로운 레이어들을 만들 수
37 | 있습니다.
38 |
39 |
48 |
49 |
50 |
51 |
Docker Image명령어
52 |
Docker에서 Image를 조작하는 명령에 대해서 알아봅시다!
53 |
54 |
1. 이미지 가져오기
55 |
56 |
57 | docker pull [Image ID | Image Name]
58 |
59 |
60 |
61 | Docker Image를 Docker Registry로부터 가져와 local에 저장합니다.
62 |
63 |
64 |
65 |
2. 이미지 확인하기
66 |
67 | docker image ls
68 |
69 |
70 | Local에 저장된 Docker Image들의 목록을 한번에 확인할 수 있습니다.
71 |
72 |
73 | Aliases:{' '}
74 |
75 | docker images
76 |
{' '}
77 | ,{' '}
78 |
79 | docker image list
80 |
81 |
82 |
83 |
84 |
85 | docker inspect [Image Name | Image ID]
86 |
87 |
88 |
89 | Local에 저장된 Docker Image 중 특정 Image의 정보를 확인할 수 있습니다.
90 |
91 |
92 |
93 |
3. 이미지 삭제하기
94 |
95 |
96 | docker image rm [Image ID | Image Name]
97 |
98 |
99 |
100 | Local에 저장된 특정 Docker Image를 삭제합니다.
101 |
102 |
103 | Aliases:{' '}
104 |
105 | docker rmi
106 |
{' '}
107 | ,{' '}
108 |
109 | docker image remove
110 |
111 |
112 |
113 |
114 |
115 |
116 | );
117 | };
118 |
119 | export default WhatIsDockerImagePage;
120 |
--------------------------------------------------------------------------------
/frontend/src/components/staticpages/DockerPage.tsx:
--------------------------------------------------------------------------------
1 | import architecture from '../../assets/docker-architecture.webp';
2 | const WhatIsDockerPage = () => {
3 | return (
4 | <>
5 |
6 | Docker란 무엇일까요?
7 |
8 |
12 |
13 | Docker는{' '}
14 |
15 | 애플리케이션을 개발, 제공 및 실행하기 위한 개방형 플랫폼
16 | {' '}
17 | 입니다.
18 |
19 | Docker 사용자는 개발한{' '}
20 |
21 | 애플리케이션을 인프라로부터 분리
22 |
23 | 하여 빠르게 제공할 수 있고, 애플리케이션을 관리하는 것과 동일한 방식으로
24 | 인프라를 관리할 수 있습니다.
25 |
26 | 코드 전달, 테스트 및 배포를 위한 Docker의 방법론을 활용하면 코드 작성 후 배포의
27 | 시간을 줄일 수 있습니다.
28 |
29 |
30 |
31 |
32 | Docker는 어떻게 사용할 수 있나요?
33 |
34 |
38 |
빠르고 일관된 애플리케이션을 제공하고 싶을때
39 |
40 |
41 | Docker는 개발자가 애플리케이션과 서비스를 실행하여 테스트할 수 있는{' '}
42 | 표준화된 환경을 로컬 컨테이너를
43 | 통해 제공합니다.
44 |
45 | 이러한 컨테이너는{' '}
46 | 지속적 통합(CI) 및{' '}
47 | 지속적 배포(CD) 워크플로우에
48 | 매우 적합합니다.
49 |
50 |
51 |
55 |
반응형 배포 및 확장을 하고 싶을때
56 |
57 |
58 | 도커의 컨테이너 기반 플랫폼은{' '}
59 | 다양한 환경에서 워크로드에
60 | 대응할 수 있도록 도와줍니다.
61 |
62 | 개발자가 운영하는 로컬 노트북, 데이터 센터의 물리적 또는 가상 시스템, 클라우드
63 | 서비스 등 여러 환경에서 도커 컨테이너를 실행할 수 있습니다.
64 |
65 | Docker는 가볍고 이식성이 뛰어나기에{' '}
66 | 워크로드를 동적으로 쉽게 관리할
67 | 수 있고, 애플리케이션과 서비스를{' '}
68 | 실시간에 가깝게 확장하거나 해체
69 | 할 수 있습니다.
70 |
71 |
72 |
76 |
77 | 동일한 하드웨어에서 더 많은 작업을 수행 및 실행하고 싶을때
78 |
79 |
80 |
81 | 도커는 가볍고 빠릅니다. 기존
82 | 하이퍼바이저 기반 가상 머신에 비해 실용적이고 비용 효율적인 대안을 제공하므로 더
83 | 많은 서버 용량을 사용하여 비즈니스 목표를 달성할 수 있습니다.
84 |
85 | 도커는 고밀도 환경과{' '}
86 | 더 적은 리소스로 더 많은 작업을
87 | 수행해야 하는 중소규모 배포 환경에 적합합니다.
88 |
89 |
90 |
91 |
92 | Docker의 Architecture
93 |
94 |
95 |
96 | 도커 데몬(Docker Daemon)
97 |
101 |
102 | 도커 데몬은 Docker API요청에 대해서
103 | listening합니다. 또한, Docker Objects에 해당하는 Container, Image, Network,
104 | Volume을 관리합니다.
105 |
106 | 또한, 도커 데몬은 다른 도커 데몬과 상호 작용할 수 있습니다.
107 |
108 |
109 | 도커 클라이언트(Docker Client)
110 |
114 |
115 | 도커 클라이언트는 많은 Docker 사용자가
116 | Docker와 상호 작용하는 주요 방법입니다.
117 |
118 | docker run과 같은 도커 명령어를 입력하면 도커 클라이언트는 Docker Daemon에게
119 | 해당 명령을 전달합니다. Docker Client는 Docker API를 활용하며, 하나 이상의
120 | Docker Daemon과 통신할 수 있습니다.
121 |
122 |
123 |
124 | 도커 레지스트리(Docker Registries)
125 |
129 |
130 | 도커 레지스트리는 Docker Image들을 저장하고
131 | 있습니다. 대표적으로 누구나 사용할 수 있는 Docker Hub가 있습니다.
132 |
133 | Docker는 기본적으로 Docker Hub를 통해 이미지를 탐색하도록 설정되어 있습니다.
134 | 또한, 자신만의 사설 레지스트리를 만들고 활용할 수도 있습니다.
135 |
136 | docker pull이나 docker run 명령의 경우 이미지를 가져와야 합니다. 필요한 이미지가
137 | 없는 경우 설정된 레지스트리로부터 해당 이미지를 가져옵니다.
138 |
139 | 자신이 만든 이미지는 docker push 명령을 통해 레지스트리에 등록할 수 있습니다.
140 |
141 |
142 |
143 | 도커 오브젝트(Docker Objects)
144 |
148 |
149 | Docker 사용자는 Docker Object들을 생성 및 사용할 수 있습니다.
150 | Docker Object에 포함되는 요소들은 다음과 같습니다.
151 |
152 |
153 | 1. Image
154 |
155 | 2. Container
156 |
157 | 3. Network
158 |
159 | 4. Volume
160 |
161 | 5. Plugins
162 |
163 | ... and more!
164 |
165 |
166 | >
167 | );
168 | };
169 |
170 | export default WhatIsDockerPage;
171 |
--------------------------------------------------------------------------------
/frontend/src/components/visualization/Arrow.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowProps } from '../../types/visualization';
2 |
3 | export const Arrow = ({
4 | icon: Icon,
5 | className,
6 | gridColumn,
7 | gridRow,
8 | isVisible = false,
9 | onAnimationEnd,
10 | }: ArrowProps) => {
11 | return (
12 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/src/components/visualization/ArrowAnimation.tsx:
--------------------------------------------------------------------------------
1 | import { Arrow } from './Arrow';
2 | import { MoveRight, MoveDownRight, MoveLeft, MoveUpRight, MoveUp } from 'lucide-react';
3 | import { AnimationProps, DOCKER_OPERATIONS } from '../../types/visualization';
4 |
5 | export const ArrowAnimation = ({ isVisible, onComplete, dockerOperation }: AnimationProps) => {
6 | switch (dockerOperation) {
7 | case DOCKER_OPERATIONS.IMAGE_PULL:
8 | return (
9 | <>
10 |
17 |
24 |
31 |
39 | >
40 | );
41 |
42 | case DOCKER_OPERATIONS.IMAGE_DELETE:
43 | return (
44 | <>
45 |
52 |
60 | >
61 | );
62 |
63 | case DOCKER_OPERATIONS.CONTAINER_CREATE:
64 | return (
65 | <>
66 |
73 |
80 |
88 | >
89 | );
90 | case DOCKER_OPERATIONS.CONTAINER_DELETE:
91 | return (
92 | <>
93 |
100 |
108 | >
109 | );
110 | case DOCKER_OPERATIONS.CONTAINER_RUN:
111 | return (
112 | <>
113 |
120 |
127 |
134 |
141 |
148 |
155 |
163 | >
164 | );
165 | case DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED:
166 | return (
167 | <>
168 |
175 |
183 | >
184 | );
185 | default:
186 | return null;
187 | }
188 | };
189 |
--------------------------------------------------------------------------------
/frontend/src/components/visualization/BaseNode.tsx:
--------------------------------------------------------------------------------
1 | import { NodeProps } from '../../types/visualization';
2 |
3 | export const BaseNode = ({ label, icon: Icon, gridRow, gridColumn }: NodeProps) => {
4 | return (
5 |
12 | {label}
13 |
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/components/visualization/ContainerNode.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ContainerNodeProps,
3 | DOCKER_CONTAINER_STATUS,
4 | Image,
5 | Container,
6 | } from '../../types/visualization';
7 | import { Popover } from 'flowbite-react';
8 | import ContainerPopover from '../popover/ContainerPopover';
9 | import ImagePopover from '../popover/ImagePopover';
10 |
11 | const STATUS_COLORS = {
12 | [DOCKER_CONTAINER_STATUS.EXITED]: 'bg-Stopped-Status-Color',
13 | [DOCKER_CONTAINER_STATUS.RESTARTING]: 'bg-Restarting-Status-Color',
14 | [DOCKER_CONTAINER_STATUS.RUNNING]: 'bg-Running-Status-Color',
15 | [DOCKER_CONTAINER_STATUS.CREATED]: 'bg-Stopped-Status-Color',
16 | [DOCKER_CONTAINER_STATUS.PAUSED]: 'bg-Stopped-Status-Color',
17 | [DOCKER_CONTAINER_STATUS.STOPPED]: 'bg-Stopped-Status-Color',
18 | [DOCKER_CONTAINER_STATUS.DEAD]: 'bg-Stopped-Status-Color',
19 | };
20 |
21 | const isContainer = (element: Image | Container): element is Container => {
22 | return (element as Container).status !== undefined;
23 | };
24 |
25 | const ContainerNode = ({
26 | label,
27 | icon: Icon,
28 | gridRow,
29 | gridColumn,
30 | containerData,
31 | }: ContainerNodeProps) => {
32 | return (
33 |
40 |
41 | {label}
42 |
43 |
44 |
45 | {containerData?.map((element) => (
46 |
51 | ) : (
52 |
53 | )
54 | }
55 | key={element.id}
56 | >
57 |
63 | {isContainer(element) ? (
64 | <>
65 |
68 |
71 |
72 |
{element.name}
73 | >
74 | ) : (
75 |
{element.name}
76 | )}
77 |
78 |
79 | ))}
80 |
81 |
82 | );
83 | };
84 |
85 | export default ContainerNode;
86 |
--------------------------------------------------------------------------------
/frontend/src/components/visualization/DockerVisualization.tsx:
--------------------------------------------------------------------------------
1 | import { Laptop, Server, Container, Database, Cloud } from 'lucide-react';
2 | import { BaseNode } from './BaseNode';
3 | import ContainerNode from './ContainerNode';
4 | import { DockerVisualizationProps } from '../../types/visualization';
5 | import { ArrowAnimation } from './ArrowAnimation';
6 |
7 | const DockerVisualization = ({
8 | animationState,
9 | elements,
10 | dockerOperation,
11 | onAnimationComplete,
12 | }: DockerVisualizationProps) => {
13 | const { images, containers } = elements;
14 | const initBaseNodes = [
15 | {
16 | label: 'client',
17 | icon: Laptop,
18 | gridColumn: 1,
19 | gridRow: 3,
20 | },
21 | {
22 | label: 'docker engine',
23 | icon: Server,
24 | gridColumn: 3,
25 | gridRow: 3,
26 | },
27 |
28 | {
29 | label: 'registry',
30 | icon: Cloud,
31 | gridColumn: 7,
32 | gridRow: 4,
33 | },
34 | ];
35 |
36 | return (
37 |
44 | {initBaseNodes.map(({ label, icon, gridRow, gridColumn }) => (
45 |
52 | ))}
53 |
61 |
69 |
70 |
76 |
77 | );
78 | };
79 |
80 | export default DockerVisualization;
81 |
--------------------------------------------------------------------------------
/frontend/src/constant/hostStatus.ts:
--------------------------------------------------------------------------------
1 | export const HOST_STATUS = {
2 | STARTING: 'STARTING',
3 | READY: 'READY',
4 | } as const;
5 |
6 | export type HostStatus = (typeof HOST_STATUS)[keyof typeof HOST_STATUS];
7 |
--------------------------------------------------------------------------------
/frontend/src/constant/quiz.ts:
--------------------------------------------------------------------------------
1 | export const CUSTOM_QUIZZES: readonly number[] = [2, 5, 7, 8];
2 |
--------------------------------------------------------------------------------
/frontend/src/constant/sidebarStatus.ts:
--------------------------------------------------------------------------------
1 | import { SidebarState } from '../types/sidebar';
2 |
3 | export const dockerImageInitStates: SidebarState[] = [
4 | {
5 | title: 'Docker image란?',
6 | path: '/what-is-docker-image',
7 | pageType: 'education',
8 | },
9 | { title: 'image 가져오기', path: '/quiz/1', pageType: 'quiz', solved: false },
10 | { title: 'image 목록 확인하기', path: '/quiz/2', pageType: 'quiz', solved: false },
11 | { title: 'image 삭제하기', path: '/quiz/3', pageType: 'quiz', solved: false },
12 | ];
13 |
14 | export const dockerContainerInitStates: SidebarState[] = [
15 | {
16 | title: 'Docker Container란?',
17 | path: '/what-is-docker-container',
18 | pageType: 'education',
19 | },
20 | {
21 | title: 'Container의 생명주기',
22 | path: '/what-is-container-lifecycle',
23 | pageType: 'education',
24 | },
25 | { title: 'Container 생성하기', path: '/quiz/4', pageType: 'quiz', solved: false },
26 | { title: 'Container 실행하기', path: '/quiz/5', pageType: 'quiz', solved: false },
27 | { title: 'Container 생성 및 실행하기', path: '/quiz/6', pageType: 'quiz', solved: false },
28 | { title: 'Container 로그 확인하기', path: '/quiz/7', pageType: 'quiz', solved: false },
29 | { title: 'Container 목록 확인하기', path: '/quiz/8', pageType: 'quiz', solved: false },
30 | { title: 'Container 중지하기', path: '/quiz/9', pageType: 'quiz', solved: false },
31 | { title: 'Container 삭제하기', path: '/quiz/10', pageType: 'quiz', solved: false },
32 | ];
33 |
--------------------------------------------------------------------------------
/frontend/src/constant/timer.ts:
--------------------------------------------------------------------------------
1 | export const SECOND = 1000;
2 | export const MINUTE = 60 * SECOND;
3 | export const HOUR = 60 * MINUTE;
4 | export const MAX_TIME = 4 * HOUR;
5 |
--------------------------------------------------------------------------------
/frontend/src/constant/visualization.ts:
--------------------------------------------------------------------------------
1 | export const COLORS = ['#000000', '#FFC107', '#4CAF50', '#2196F3', '#673AB7', '#E91E63'];
2 | export const STATE_CHANGE_COMMAND_REGEX =
3 | /^docker\s+(run|create|start|stop|pull|rmi|rm|restart|pause|unpause|rename|attach|tag|build|load|commit|kill)(\s|$)/;
4 | export const IMAGEID_PREFIX_INDEX = 7;
5 |
--------------------------------------------------------------------------------
/frontend/src/constant/xterm.ts:
--------------------------------------------------------------------------------
1 | export const ENTER_KEY = '\r';
2 | export const BACKSPACE_KEY = '\x7f';
3 |
--------------------------------------------------------------------------------
/frontend/src/handlers/handler.ts:
--------------------------------------------------------------------------------
1 | export const handleBeforeUnload = (e: BeforeUnloadEvent) => {
2 | e.preventDefault();
3 | return '';
4 | };
5 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useAlert.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export const useAlert = () => {
4 | const [openAlert, setOpenAlert] = useState(false);
5 | const [message, setMessage] = useState('');
6 |
7 | const showAlert = (alertMessage: string) => {
8 | setMessage(alertMessage);
9 | setOpenAlert(true);
10 | setTimeout(() => {
11 | setOpenAlert(false);
12 | }, 3000);
13 | }
14 | return { openAlert, showAlert, message };
15 | }
--------------------------------------------------------------------------------
/frontend/src/hooks/useDockerVisualization.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react';
2 | import { requestVisualizationData } from '../api/quiz';
3 | import { DOCKER_OPERATIONS, AnimationState, DockerOperation } from '../types/visualization';
4 | import { useNavigate } from 'react-router-dom';
5 | import { Visualization } from '../types/visualization';
6 | import {
7 | setColorToElements,
8 | updateImageColors,
9 | updateContainerColors,
10 | getDockerOperation,
11 | } from '../utils/visualizationUtils';
12 | import { STATE_CHANGE_COMMAND_REGEX } from '../constant/visualization';
13 |
14 | const useDockerVisualization = () => {
15 | const navigate = useNavigate();
16 | const [elements, setElements] = useState({ images: [], containers: [] });
17 | const [dockerOperation, setDockerOperation] = useState();
18 | const [animation, setAnimation] = useState({
19 | isVisible: false,
20 | key: 0,
21 | });
22 | const elementsRef = useRef({ images: [], containers: [] });
23 |
24 | const handleImageStateLengthChange = (
25 | prevElements: Visualization,
26 | newElements: Visualization,
27 | dockerOperation: DockerOperation
28 | ) => {
29 | const updatedImages = updateImageColors(prevElements, newElements);
30 |
31 | elementsRef.current.images = [...updatedImages];
32 | setDockerOperation(dockerOperation);
33 | setAnimation((prev) => ({
34 | isVisible: true,
35 | key: prev.key + 1,
36 | }));
37 | };
38 |
39 | const handleContainerStateLengthChange = (
40 | newElements: Visualization,
41 | dockerOperation: DockerOperation
42 | ) => {
43 | const prevImages = elementsRef.current;
44 | const updatedContainers = updateContainerColors(prevImages, newElements);
45 |
46 | elementsRef.current.containers = [...updatedContainers];
47 | setDockerOperation(dockerOperation);
48 | setAnimation((prev) => ({
49 | isVisible: true,
50 | key: prev.key + 1,
51 | }));
52 | };
53 |
54 | const handleElementsStateLengthChange = (newElements: Visualization) => {
55 | const prevElements = elementsRef.current;
56 | const { initImages, initContainers } = setColorToElements(prevElements, newElements);
57 |
58 | elementsRef.current.images = [...initImages];
59 | elementsRef.current.containers = [...initContainers];
60 | setDockerOperation(DOCKER_OPERATIONS.CONTAINER_RUN);
61 | setAnimation((prev) => ({
62 | isVisible: true,
63 | key: prev.key + 1,
64 | }));
65 | };
66 |
67 | const handleContainerStateChange = (newElements: Visualization, command: string) => {
68 | const prevImages = elementsRef.current;
69 | const updatedContainers = updateContainerColors(prevImages, newElements);
70 | const elements = { images: prevImages.images, containers: updatedContainers };
71 |
72 | elementsRef.current.containers = [...updatedContainers];
73 | setDockerOperation(DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED);
74 |
75 | if (command.match(STATE_CHANGE_COMMAND_REGEX)) {
76 | setAnimation((prev) => ({
77 | isVisible: true,
78 | key: prev.key + 1,
79 | }));
80 | } else {
81 | setElements(elements);
82 | }
83 | };
84 |
85 | const handleTerminalEnterCallback = (data: Visualization, command: string) => {
86 | const prevElements = elementsRef.current;
87 | const newElements = data;
88 | const operation = getDockerOperation(prevElements, newElements);
89 |
90 | switch (operation) {
91 | case DOCKER_OPERATIONS.IMAGE_PULL:
92 | handleImageStateLengthChange(prevElements, newElements, operation);
93 | break;
94 | case DOCKER_OPERATIONS.IMAGE_DELETE:
95 | handleImageStateLengthChange(prevElements, newElements, operation);
96 | break;
97 | case DOCKER_OPERATIONS.CONTAINER_CREATE:
98 | handleContainerStateLengthChange(newElements, operation);
99 | break;
100 | case DOCKER_OPERATIONS.CONTAINER_RUN:
101 | handleElementsStateLengthChange(newElements);
102 | break;
103 | case DOCKER_OPERATIONS.CONTAINER_DELETE:
104 | handleContainerStateLengthChange(newElements, operation);
105 | break;
106 | case DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED:
107 | handleContainerStateChange(newElements, command);
108 | break;
109 | }
110 | };
111 |
112 | const updateVisualizationData = async (command: string) => {
113 | const data = await requestVisualizationData(navigate);
114 | if (!data) return;
115 | handleTerminalEnterCallback(data, command);
116 | };
117 |
118 | const setInitVisualization = async () => {
119 | const newElements = await requestVisualizationData(navigate);
120 | if (!newElements) return;
121 |
122 | const prevElements = elementsRef.current;
123 | const { initImages, initContainers } = setColorToElements(prevElements, newElements);
124 |
125 | elementsRef.current.images = [...initImages];
126 | elementsRef.current.containers = [...initContainers];
127 | setElements({ images: initImages, containers: initContainers });
128 | };
129 |
130 | const handleAnimationComplete = () => {
131 | const { images, containers } = elementsRef.current;
132 |
133 | setElements({ images, containers });
134 | setAnimation((prev) => ({
135 | isVisible: false,
136 | key: prev.key,
137 | }));
138 | };
139 |
140 | return {
141 | elements,
142 | animationState: animation,
143 | dockerOperation,
144 | onAnimationComplete: handleAnimationComplete,
145 | updateVisualizationData,
146 | handleTerminalEnterCallback,
147 | setInitVisualization,
148 | };
149 | };
150 |
151 | export default useDockerVisualization;
152 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useHostStatus.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { requestHostStatus } from '../api/quiz';
4 | import { HostStatus, HOST_STATUS } from '../constant/hostStatus';
5 |
6 | type UseHostStatusProps = {
7 | setInitVisualization: () => Promise;
8 | };
9 |
10 | export const useHostStatus = ({ setInitVisualization }: UseHostStatusProps) => {
11 | const navigate = useNavigate();
12 | const [hostStatus, setHostStatus] = useState(HOST_STATUS.STARTING);
13 | const pollingRef = useRef(true);
14 | const pollingIntervalRef = useRef(null);
15 |
16 | const checkHostStatus = async () => {
17 | const response = await requestHostStatus(navigate);
18 |
19 | if (!response) {
20 | return;
21 | }
22 |
23 | setHostStatus(response);
24 |
25 | if (response === HOST_STATUS.READY) {
26 | pollingRef.current = false;
27 | if (pollingIntervalRef.current) {
28 | clearInterval(pollingIntervalRef.current);
29 | }
30 | }
31 | };
32 |
33 | useEffect(() => {
34 | const initializeHostStatus = async () => {
35 | await checkHostStatus();
36 |
37 | if (pollingRef.current) {
38 | pollingIntervalRef.current = setInterval(checkHostStatus, 1000);
39 | } else {
40 | setInitVisualization();
41 | }
42 | };
43 |
44 | initializeHostStatus();
45 |
46 | return () => {
47 | if (pollingIntervalRef.current) {
48 | clearInterval(pollingIntervalRef.current);
49 | pollingIntervalRef.current = null;
50 | }
51 | };
52 |
53 | // eslint-disable-next-line react-hooks/exhaustive-deps
54 | }, []);
55 |
56 | return hostStatus;
57 | };
58 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useQuizData.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { useNavigate } from 'react-router-dom';
3 | import { requestQuizData } from '../api/quiz';
4 |
5 | export const useQuizData = (quizId: string) => {
6 | const navigate = useNavigate();
7 |
8 | const {
9 | data: quizData,
10 | isPending,
11 | isError,
12 | } = useQuery({
13 | queryKey: ['quiz', quizId],
14 | queryFn: () => requestQuizData(quizId, navigate),
15 | staleTime: Infinity, // 데이터를 항상 fresh하게 유지
16 | gcTime: Infinity, // 캐시를 영구적으로 유지
17 | });
18 |
19 | return {
20 | id: quizData?.id ?? 0,
21 | title: quizData?.title ?? '',
22 | content: quizData?.content ?? '',
23 | hint: quizData?.hint ?? '',
24 | isPending,
25 | isError,
26 | };
27 | };
28 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useSidebar.ts:
--------------------------------------------------------------------------------
1 | import { dockerImageInitStates, dockerContainerInitStates } from '../constant/sidebarStatus';
2 | import { useState } from 'react';
3 | import { SidebarElementsProps, SidebarState } from '../types/sidebar';
4 | import { updateSidebarState } from '../utils/sidebarUtils';
5 |
6 | export const useSidebar = () => {
7 | const imageStates = [...dockerImageInitStates];
8 | const containerStates = [...dockerContainerInitStates];
9 | const getSidebarInitState = (
10 | imageStates: SidebarState[],
11 | containerStates: SidebarState[],
12 | quizNum: number
13 | ) => {
14 | updateSidebarState(imageStates, quizNum - 1);
15 |
16 | if (4 <= quizNum) {
17 | updateSidebarState(containerStates, quizNum - 1);
18 | }
19 | return {
20 | dockerImageStates: imageStates,
21 | dockerContainerStates: containerStates,
22 | };
23 | };
24 | const { dockerImageStates, dockerContainerStates } = getSidebarInitState(
25 | imageStates,
26 | containerStates,
27 | Number(sessionStorage.getItem('quiz'))
28 | ) as SidebarElementsProps;
29 | const [sidebarStates, setSidebarStates] = useState({
30 | dockerImageStates,
31 | dockerContainerStates,
32 | });
33 | return { sidebarStates, setSidebarStates };
34 | };
35 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useTerminal.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { Terminal } from '@xterm/xterm';
3 | import {
4 | handleEnter,
5 | handleBackspace,
6 | handleDefaultKey,
7 | isPrintableKey,
8 | } from '../utils/terminalUtils';
9 | import { ENTER_KEY, BACKSPACE_KEY } from '../constant/xterm';
10 | import { HttpStatusCode } from 'axios';
11 | import { BrowserClipboardProvider, ClipboardSelectionType } from '@xterm/addon-clipboard';
12 |
13 | export function useTerminal(
14 | updateVisualizationData: (command: string) => Promise,
15 | showAlert: (alertMessage: string) => void
16 | ) {
17 | const currentLineRef = useRef('');
18 | const blockingRef = useRef(false);
19 | const tooManyRequestRef = useRef(false);
20 |
21 | const handleCommandError = (term: Terminal, statusCode: number, errorMessage: string) => {
22 | if (!term) return;
23 | if (statusCode === HttpStatusCode.TooManyRequests) {
24 | showAlert('잠시후 다시 시도해주세요');
25 |
26 | tooManyRequestRef.current = true;
27 | setTimeout(() => {
28 | tooManyRequestRef.current = false;
29 | }, 1000);
30 |
31 | return;
32 | }
33 |
34 | const message = errorMessage || '허용되지 않은 명령어 입니다.';
35 | term.write(`\x1b[91m${message}\x1b[0m\r\n`);
36 | };
37 |
38 | const handleKeyInput = async (
39 | term: Terminal,
40 | event: { key: string; domEvent: KeyboardEvent },
41 | clipboardProvider: BrowserClipboardProvider
42 | ) => {
43 | if (blockingRef.current || tooManyRequestRef.current) return;
44 |
45 | if ((event.domEvent.metaKey || event.domEvent.ctrlKey) && event.domEvent.key === 'c') {
46 | const selection = term.getSelection();
47 | if (selection) {
48 | await clipboardProvider.writeText('c' as ClipboardSelectionType, selection);
49 | }
50 | return;
51 | }
52 |
53 | if ((event.domEvent.metaKey || event.domEvent.ctrlKey) && event.domEvent.key === 'v') {
54 | try {
55 | const text = await clipboardProvider.readText('c' as ClipboardSelectionType);
56 | term.write(text);
57 | currentLineRef.current += text;
58 | } catch (err) {
59 | console.error('Failed to paste:', err);
60 | }
61 | return;
62 | }
63 |
64 | switch (event.key) {
65 | case ENTER_KEY: {
66 | blockingRef.current = true;
67 | await handleEnter(
68 | term,
69 | currentLineRef.current.trim(),
70 | handleCommandError,
71 | updateVisualizationData
72 | );
73 | currentLineRef.current = '';
74 | blockingRef.current = false;
75 | break;
76 | }
77 |
78 | case BACKSPACE_KEY: {
79 | currentLineRef.current = handleBackspace(term, currentLineRef.current);
80 | break;
81 | }
82 |
83 | default: {
84 | if (!isPrintableKey(event.key)) return;
85 | currentLineRef.current = handleDefaultKey(term, event.key, currentLineRef.current);
86 | break;
87 | }
88 | }
89 | };
90 |
91 | return { handleKeyInput };
92 | }
93 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .custom-terminal {
6 | scrollbar-width: thin;
7 | scrollbar-color: #666 #1e1e1e;
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import './index.css';
3 | import App from './App.tsx';
4 | import { BrowserRouter } from 'react-router-dom';
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/frontend/src/types/quiz.ts:
--------------------------------------------------------------------------------
1 | export type Quiz = {
2 | id: number;
3 | title: string;
4 | content: string | undefined;
5 | hint: string | undefined;
6 | };
7 |
8 | export type SubmitStatus = 'SUCCESS' | 'FAIL';
9 |
10 | export type QuizResult = {
11 | quizResult: SubmitStatus;
12 | };
13 |
14 | export type QuizTextAreaProps = {
15 | updateVisualizationData: () => Promise;
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/types/sidebar.ts:
--------------------------------------------------------------------------------
1 | export type SidebarSectionProps = {
2 | title: string;
3 | links: SidebarState[];
4 | };
5 |
6 | export type SidebarElementsProps = {
7 | dockerImageStates: Array;
8 | dockerContainerStates: Array;
9 | };
10 |
11 | export type SidebarState = {
12 | title: string;
13 | path: string;
14 | pageType: string;
15 | solved?: boolean;
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/types/timer.ts:
--------------------------------------------------------------------------------
1 | export type ExpirationTime = {
2 | endDate: string;
3 | };
4 |
--------------------------------------------------------------------------------
/frontend/src/types/visualization.ts:
--------------------------------------------------------------------------------
1 | import { LucideIcon } from 'lucide-react';
2 |
3 | export type Visualization = {
4 | containers: Container[];
5 | images: Image[];
6 | };
7 |
8 | export type Image = {
9 | id: string;
10 | name: string;
11 | color?: string;
12 | };
13 |
14 | export type Container = {
15 | id: string;
16 | name: string;
17 | image: string;
18 | status: string;
19 | color?: string;
20 | };
21 |
22 | export type AnimationState = {
23 | isVisible: boolean;
24 | key: number;
25 | };
26 |
27 | export type NodeProps = {
28 | label: string;
29 | icon: LucideIcon;
30 | gridColumn: number;
31 | gridRow: number;
32 | };
33 |
34 | export type ContainerNodeProps = {
35 | label: string;
36 | icon: LucideIcon;
37 | gridColumn: number;
38 | gridRow: number;
39 | containerData: Image[] | Container[] | undefined;
40 | };
41 |
42 | export type DockerVisualizationProps = {
43 | animationState: AnimationState;
44 | elements: Visualization;
45 | dockerOperation: DockerOperation | undefined;
46 | onAnimationComplete: () => void;
47 | };
48 |
49 | export type ArrowProps = {
50 | icon: LucideIcon;
51 | className?: string;
52 | gridColumn: number;
53 | gridRow: number;
54 | isVisible?: boolean;
55 | onAnimationEnd?: () => void;
56 | };
57 |
58 | export type AnimationProps = {
59 | isVisible: boolean;
60 | onComplete: () => void;
61 | dockerOperation: DockerOperation | undefined;
62 | };
63 |
64 | export const DOCKER_OPERATIONS = {
65 | IMAGE_PULL: 'IMAGE_PULL',
66 | IMAGE_DELETE: 'IMEAGE_DELETE',
67 | CONTAINER_CREATE: 'CONTAINER_CREATE',
68 | CONTAINER_DELETE: 'CONTAINER_DELETE',
69 | CONTAINER_RUN: 'CONTAINER_RUN',
70 | CONTAINER_STATUS_CHANGED: 'CONTAINER_STATUS_CHANGED',
71 | } as const;
72 |
73 | export const DOCKER_CONTAINER_STATUS = {
74 | EXITED: 'exited',
75 | RUNNING: 'running',
76 | CREATED: 'created',
77 | PAUSED: 'paused',
78 | STOPPED: 'stopped',
79 | DEAD: 'dead',
80 | RESTARTING: 'restarting',
81 | };
82 |
83 | export type DockerOperation = (typeof DOCKER_OPERATIONS)[keyof typeof DOCKER_OPERATIONS];
84 |
--------------------------------------------------------------------------------
/frontend/src/utils/LoadingTerminal.ts:
--------------------------------------------------------------------------------
1 | import { Terminal } from '@xterm/xterm';
2 |
3 | export default class LoadingTerminal {
4 | private term: Terminal;
5 | private spinnerFrames: string[];
6 | private hostSpinnerFrames: string[];
7 | private currentFrame: number;
8 | private loadingInterval: number;
9 | private loadingTimeout: number;
10 |
11 | constructor(term: Terminal) {
12 | this.term = term;
13 | this.spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
14 | this.hostSpinnerFrames = [
15 | '🐳 ∘°◦ ',
16 | ' 🐳 ∘°◦ ',
17 | ' 🐳 ∘°◦ ',
18 | ' 🐳 ∘°◦ ',
19 | ' 🐳 ∘°◦ ',
20 | ' 🐳 ∘°◦ ',
21 | ' 🐳 ∘°◦ ',
22 | ' 🐳 ∘°◦ ',
23 | ' 🐳 ∘°◦ ',
24 | ' 🐳 ∘°◦',
25 | '◦ 🐳 ∘°',
26 | '°◦ 🐳 ∘',
27 | '∘°◦ 🐳 ',
28 | ' ∘°◦ 🐳 ',
29 | ' ∘°◦ 🐳',
30 | ];
31 | this.currentFrame = 0;
32 | this.loadingInterval = 0;
33 | this.loadingTimeout = 0;
34 | }
35 |
36 | public spinnerStart(timeDelay: number = 1500) {
37 | this.loadingTimeout = setTimeout(() => {
38 | this.showSpinner();
39 | }, timeDelay);
40 | }
41 |
42 | private showSpinner() {
43 | this.loadingInterval = setInterval(() => {
44 | this.term.write(`\r${this.spinnerFrames[this.currentFrame]} 실행 중...`);
45 | this.currentFrame = (this.currentFrame + 1) % this.spinnerFrames.length;
46 | }, 80);
47 | }
48 |
49 | public spinnerStop() {
50 | if (this.loadingTimeout) {
51 | clearTimeout(this.loadingTimeout);
52 | this.loadingTimeout = 0;
53 | }
54 | clearInterval(this.loadingInterval);
55 | this.term.write('\r\x1b[2K');
56 | }
57 |
58 | public hostSpinnerStart(timeDelay: number = 500) {
59 | this.loadingTimeout = setTimeout(() => {
60 | this.term.write('\r도커 컨테이너 준비중...\r\n');
61 | this.showHostSpinner();
62 | }, timeDelay);
63 | }
64 |
65 | private showHostSpinner() {
66 | this.loadingInterval = setInterval(() => {
67 | this.term.write(`\r${this.hostSpinnerFrames[this.currentFrame]}`);
68 | this.currentFrame = (this.currentFrame + 1) % this.hostSpinnerFrames.length;
69 | }, 200);
70 | }
71 |
72 | public hostSpinnerStop() {
73 | if (this.loadingTimeout) {
74 | clearTimeout(this.loadingTimeout);
75 | this.loadingTimeout = 0;
76 | }
77 | clearInterval(this.loadingInterval);
78 | this.term.clear();
79 | this.term.write('\r\x1b[2K');
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/frontend/src/utils/sidebarUtils.ts:
--------------------------------------------------------------------------------
1 | import { SidebarState } from '../types/sidebar';
2 |
3 | export const updateSidebarState = (states: Array, quizNumber: number) => {
4 | states.forEach((state) => {
5 | const currentQuizNum = Number(state.path.split('/').slice(-1));
6 | if (state.pageType === 'quiz' && currentQuizNum <= quizNumber) {
7 | state.solved = true;
8 | }
9 | });
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/src/utils/terminalUtils.ts:
--------------------------------------------------------------------------------
1 | import { Terminal } from '@xterm/xterm';
2 | import { FitAddon } from '@xterm/addon-fit';
3 | import { BrowserClipboardProvider, ClipboardAddon } from '@xterm/addon-clipboard';
4 | import { requestCommandResult } from '../api/quiz';
5 |
6 | export function createTerminal(container: HTMLElement): {
7 | terminal: Terminal;
8 | clipboardProvider: BrowserClipboardProvider;
9 | fitAddon: FitAddon;
10 | } {
11 | const terminal = new Terminal({
12 | cursorBlink: true,
13 | fontFamily: '"Noto Sans Mono", "Noto Sans KR", courier-new, courier, monospace',
14 | fontSize: 14,
15 | rows: 13,
16 | fontWeight: '300',
17 | });
18 |
19 | const clipboardProvider = new BrowserClipboardProvider();
20 | const clipboardAddon = new ClipboardAddon(clipboardProvider);
21 | const fitAddon = new FitAddon();
22 |
23 | terminal.loadAddon(clipboardAddon);
24 | terminal.loadAddon(fitAddon);
25 |
26 | terminal.open(container);
27 |
28 | const handleResize = () => {
29 | terminal.resize(4, 13);
30 | fitAddon.fit();
31 | };
32 |
33 | window.addEventListener('resize', handleResize);
34 |
35 | const originalDispose = terminal.dispose.bind(terminal);
36 | terminal.dispose = () => {
37 | window.removeEventListener('resize', handleResize);
38 | clipboardAddon.dispose();
39 | originalDispose();
40 | };
41 |
42 | return { terminal, clipboardProvider, fitAddon };
43 | }
44 |
45 | const handleClear = (term: Terminal) => {
46 | term.clear();
47 | term.write('\x1b[2K\r~$ ');
48 | };
49 |
50 | export const handleBackspace = (term: Terminal, currentLine: string) => {
51 | if (currentLine.length > 0) {
52 | term.write('\b \b');
53 | return currentLine.slice(0, -1);
54 | }
55 | return currentLine;
56 | };
57 |
58 | export const handleEnter = async (
59 | term: Terminal,
60 | command: string,
61 | handleCommandError: (term: Terminal, statusCode: number, errorMessage: string) => void,
62 | updateVisualization: (command: string) => Promise
63 | ) => {
64 | if (!command) {
65 | term.write('\r\n~$ ');
66 | return;
67 | }
68 |
69 | if (command === 'clear') {
70 | handleClear(term);
71 | return;
72 | }
73 |
74 | term.write('\r\n');
75 | const commandResponse = await requestCommandResult(command, term, handleCommandError);
76 |
77 | if (commandResponse !== null) {
78 | term.write(commandResponse);
79 | }
80 | await updateVisualization(command);
81 |
82 | term.write('\r\n~$ ');
83 | };
84 |
85 | export const handleDefaultKey = (term: Terminal, key: string, currentLine: string) => {
86 | term.write(key);
87 | return currentLine + key;
88 | };
89 |
90 | export const isPrintableKey = (key: string): boolean => {
91 | // 아스키 32 ~ 126 사이는 출력 가능한 문자
92 | return key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) <= 126;
93 | };
94 |
--------------------------------------------------------------------------------
/frontend/src/utils/visualizationUtils.ts:
--------------------------------------------------------------------------------
1 | import { Visualization } from '../types/visualization';
2 | import { COLORS } from '../constant/visualization';
3 | import { DOCKER_OPERATIONS } from '../types/visualization';
4 | import { IMAGEID_PREFIX_INDEX } from '../constant/visualization';
5 |
6 | export const setColorToElements = (prevElements: Visualization, newElements: Visualization) => {
7 | const initImages = updateImageColors(prevElements, newElements);
8 | const initContainers = updateContainerColors(prevElements, newElements);
9 |
10 | return { initImages, initContainers };
11 | };
12 |
13 | export const updateImageColors = (prevElements: Visualization, newElements: Visualization) => {
14 | const { images: prevImages } = prevElements;
15 | const { images: newImages } = newElements;
16 | return newImages.map((newImage) => {
17 | const prevImage = prevImages.find((img) => img.id === newImage.id);
18 | if (prevImage && Object.keys(prevImage).includes('color')) {
19 | return prevImage;
20 | }
21 | const newData = {
22 | ...newImage,
23 | color: getNotUsedColor(prevElements),
24 | };
25 | prevImages.push(newData);
26 | return newData;
27 | });
28 | };
29 |
30 | const getNotUsedColor = (prevElements: Visualization) => {
31 | const { images } = prevElements;
32 | const notUsedColors = COLORS.filter((color) => {
33 | return !images.some((image) => image.color === color);
34 | });
35 | return notUsedColors[0];
36 | };
37 |
38 | export const updateContainerColors = (prevElements: Visualization, newElements: Visualization) => {
39 | const { images: coloredImages } = prevElements;
40 | const { containers } = newElements;
41 | return containers.map((container) => {
42 | const image = coloredImages.find((image) => {
43 | return compareImageId(image.id, container.image) || image.name === container.image;
44 | });
45 | return {
46 | ...container,
47 | color: image?.color,
48 | };
49 | });
50 | };
51 |
52 | const compareImageId = (imageId: string, containerImage: string) => {
53 | const containerImageLen = containerImage.length;
54 | return (
55 | imageId.slice(IMAGEID_PREFIX_INDEX, IMAGEID_PREFIX_INDEX + containerImageLen) ===
56 | containerImage
57 | );
58 | };
59 |
60 | const isChangedContainerStatus = (prevElements: Visualization, newElements: Visualization) => {
61 | const { containers: prevContainers } = prevElements;
62 | const { containers: currentContainers } = newElements;
63 | return prevContainers.some((prevContainer) => {
64 | const matchedContainer = currentContainers.find(
65 | (currentContainer) => currentContainer.id === prevContainer.id
66 | );
67 | if (!matchedContainer) {
68 | throw new Error('isChangedContainerStatus함수 undefined 에러');
69 | }
70 | return matchedContainer.status !== prevContainer.status;
71 | });
72 | };
73 |
74 | export const getDockerOperation = (prevElements: Visualization, newElements: Visualization) => {
75 | const { images: newImages, containers: newContainers } = newElements;
76 | const { images: prevImages, containers: prevContainers } = prevElements;
77 | if (newImages.length > prevImages.length && newContainers.length === prevContainers.length)
78 | return DOCKER_OPERATIONS.IMAGE_PULL;
79 | if (newImages.length < prevImages.length) return DOCKER_OPERATIONS.IMAGE_DELETE;
80 | if (prevImages.length === newImages.length && prevContainers.length < newContainers.length)
81 | return DOCKER_OPERATIONS.CONTAINER_CREATE;
82 | if (prevImages.length < newImages.length && prevContainers.length < newContainers.length)
83 | return DOCKER_OPERATIONS.CONTAINER_RUN;
84 | if (prevContainers.length > newContainers.length) return DOCKER_OPERATIONS.CONTAINER_DELETE;
85 | if (isChangedContainerStatus(prevElements, newElements))
86 | return DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED;
87 | };
88 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import flowbite from 'flowbite-react/tailwind';
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | export default {
5 | content: ['./index.html', './src/**/*.{js,jsx,tsx,ts}', flowbite.content()],
6 | theme: {
7 | extend: {
8 | fontFamily: {
9 | pretendard: ['Pretendard', 'sans-serif'],
10 | },
11 | colors: {
12 | 'Moby-Blue': '#1D63ED',
13 | 'Dark-Blue': '#00084D',
14 | 'Light-Blue': '#E5F2FC',
15 | 'Off-Black': '#17191E',
16 | 'Secondary-Green': '#58C126',
17 | 'Secondary-Red': '#C12626',
18 | 'Stopped-Status-Color': '#FF0000',
19 | 'Running-Status-Color': '#00FF00',
20 | 'Restarting-Status-Color': '#FFFF00',
21 | },
22 | },
23 |
24 | keyframes: {
25 | showAndHideFirst: {
26 | '0%': { opacity: '0', visibility: 'hidden' },
27 | '1%, 14.28%': { opacity: '1', visibility: 'visible' },
28 | '14.29%, 100%': { opacity: '0', visibility: 'hidden' },
29 | },
30 | showAndHideSecond: {
31 | '0%, 14.28%': { opacity: '0', visibility: 'hidden' },
32 | '14.29%, 28.57%': { opacity: '1', visibility: 'visible' },
33 | '28.58%, 100%': { opacity: '0', visibility: 'hidden' },
34 | },
35 | showAndHideThird: {
36 | '0%, 28.57%': { opacity: '0', visibility: 'hidden' },
37 | '28.58%, 42.86%': { opacity: '1', visibility: 'visible' },
38 | '42.87%, 100%': { opacity: '0', visibility: 'hidden' },
39 | },
40 | showAndHideFourth: {
41 | '0%, 42.86%': { opacity: '0', visibility: 'hidden' },
42 | '42.87%, 57.14%': { opacity: '1', visibility: 'visible' },
43 | '57.15%, 100%': { opacity: '0', visibility: 'hidden' },
44 | },
45 | showAndHideFifth: {
46 | '0%, 57.14%': { opacity: '0', visibility: 'hidden' },
47 | '57.15%, 71.43%': { opacity: '1', visibility: 'visible' },
48 | '71.44%, 100%': { opacity: '0', visibility: 'hidden' },
49 | },
50 | showAndHideSixth: {
51 | '0%, 71.43%': { opacity: '0', visibility: 'hidden' },
52 | '71.44%, 85.71%': { opacity: '1', visibility: 'visible' },
53 | '85.72%, 100%': { opacity: '0', visibility: 'hidden' },
54 | },
55 | showAndHideSeventh: {
56 | '0%, 85.71%': { opacity: '0', visibility: 'hidden' },
57 | '85.72%, 100%': { opacity: '1', visibility: 'visible' },
58 | '100%': { opacity: '0', visibility: 'hidden' },
59 | },
60 | ping: {
61 | '75%, 100%': {
62 | transform: 'scale(2)',
63 | opacity: 0,
64 | },
65 | },
66 | },
67 | animation: {
68 | showAndHideFirst: 'showAndHideFirst 2s linear forwards',
69 | showAndHideSecond: 'showAndHideSecond 2s linear forwards',
70 | showAndHideThird: 'showAndHideThird 2s linear forwards',
71 | showAndHideFourth: 'showAndHideFourth 2s linear forwards',
72 | showAndHideFifth: 'showAndHideFifth 2s linear forwards',
73 | showAndHideSixth: 'showAndHideSixth 2s linear forwards',
74 | showAndHideSeventh: 'showAndHideSeventh 2s linear forwards',
75 | ping: `ping 1s cubic-bezier(0, 0, 0.2, 1) infinite`,
76 | },
77 | },
78 | plugins: [flowbite.plugin()],
79 | };
80 |
--------------------------------------------------------------------------------
/frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "Bundler",
13 | "allowImportingTsExtensions": true,
14 | "isolatedModules": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedIndexedAccess": true
25 | },
26 | "include": ["src"]
27 | }
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "target": "ES2022",
6 | "lib": ["ES2023"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "Bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedIndexedAccess": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig(({ mode }) => {
6 | const env = loadEnv(mode, '../');
7 |
8 | const apiHost = env.VITE_PROXY_HOST || 'localhost';
9 | const apiPort = env.VITE_PROXY_PORT || '3000';
10 | const apiUrl = `http://${apiHost}:${apiPort}`;
11 |
12 | return {
13 | plugins: [react()],
14 | server: {
15 | proxy: {
16 | '/api': {
17 | target: apiUrl,
18 | changeOrigin: true,
19 | },
20 | },
21 | },
22 | };
23 | });
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web34-learndocker",
3 | "version": "1.0.0",
4 | "description": "## 프로젝트 개요 > 고래🐳와 함께 자신만의 Docker 환경에서 학습해볼까요?",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "pnpm -F frontend build && pnpm -F backend start",
8 | "dev": "concurrently --raw \"pnpm -F backend start:dev\" \"pnpm -F frontend dev\"",
9 | "lint": "eslint . ",
10 | "prettier": "prettier --write ."
11 | },
12 | "author": "",
13 | "license": "ISC",
14 | "devDependencies": {
15 | "@eslint/js": "^9.14.0",
16 | "@types/eslint__js": "^8.42.3",
17 | "@typescript-eslint/eslint-plugin": "^8.13.0",
18 | "concurrently": "^9.1.0",
19 | "eslint": "^9.14.0",
20 | "eslint-config-prettier": "^9.1.0",
21 | "eslint-plugin-react": "^7.37.2",
22 | "eslint-plugin-react-hooks": "^5.0.0",
23 | "eslint-plugin-react-refresh": "^0.4.14",
24 | "globals": "^15.12.0",
25 | "prettier": "^3.3.3",
26 | "typescript": "^5.6.3",
27 | "typescript-eslint": "^8.13.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'frontend/'
3 | - 'backend/'
4 |
--------------------------------------------------------------------------------
/sandbox/host-container/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM docker:dind
2 |
3 | # learndocker.io가 가리킬 host IP 주소 (172.19.0.1)에 HTTP 요청을 허용하기 위해 필요함
4 | RUN mkdir -p /etc/docker
5 | RUN echo '{ "insecure-registries":["learndocker.io"] }' > /etc/docker/daemon.json
6 |
--------------------------------------------------------------------------------
/sandbox/host-container/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | dind:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | image: dind
7 | privileged: true
8 | ports:
9 | - "0:2375"
10 | environment:
11 | - DOCKER_TLS_CERTDIR=
12 | extra_hosts:
13 | - "learndocker.io:172.17.0.1"
14 | network_mode: "bridge"
--------------------------------------------------------------------------------
/sandbox/quiz-images/hello-world/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest AS builder
2 | WORKDIR /app
3 | COPY hello.c .
4 | RUN apk add --no-cache gcc musl-dev \
5 | && gcc -static -Os -fno-ident -fdata-sections -ffunction-sections \
6 | -fno-asynchronous-unwind-tables -fno-unwind-tables \
7 | -Wl,--gc-sections -Wl,--strip-all \
8 | -o hello hello.c
9 |
10 | FROM scratch
11 | COPY --from=builder /app/hello /hello
12 | ENTRYPOINT [ "/hello" ]
--------------------------------------------------------------------------------
/sandbox/quiz-images/hello-world/hello.c:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | int main() {
4 | char answer[45] = "부스트캠프 웹모바일 9기 화이팅!";
5 | printf("Answer: %s\n", answer);
6 | return 0;
7 | }
--------------------------------------------------------------------------------
/sandbox/quiz-images/joke/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest AS builder
2 | WORKDIR /app
3 | COPY joke.c .
4 | RUN apk add --no-cache gcc musl-dev \
5 | && gcc -static -Os -fno-ident -fdata-sections -ffunction-sections \
6 | -fno-asynchronous-unwind-tables -fno-unwind-tables \
7 | -Wl,--gc-sections -Wl,--strip-all \
8 | -o joke joke.c
9 |
10 | FROM scratch
11 | COPY --from=builder /app/joke /joke
12 | ENTRYPOINT [ "/joke" ]
--------------------------------------------------------------------------------
/sandbox/quiz-images/joke/joke.c:
--------------------------------------------------------------------------------
1 | #include
2 | #include
3 |
4 | static sig_atomic_t running = 1;
5 |
6 | void signal_handler(int signum) {
7 | if (signum == SIGTERM) {
8 | running = 0;
9 | }
10 | }
11 |
12 | size_t strlen(const char *str) {
13 | const char *s;
14 | for (s = str; *s; ++s)
15 | ;
16 | return s - str;
17 | }
18 |
19 | int main() {
20 | signal(SIGTERM, signal_handler);
21 |
22 | const char *msg = "넌센스 퀴즈입니다!\n우주인이 술 마시는 장소는?\n";
23 |
24 | write(STDOUT_FILENO, msg, strlen(msg));
25 |
26 | while(running) {
27 | sleep(2);
28 | }
29 |
30 |
31 | return 0;
32 | }
--------------------------------------------------------------------------------
/sandbox/registry/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | registry:
3 | image: registry:2
4 | ports:
5 | - "80:80"
6 | environment:
7 | REGISTRY_HTTP_ADDR: 0.0.0.0:80
8 | restart: always
9 | network_mode: "bridge"
--------------------------------------------------------------------------------
/sandbox/setup.ps1:
--------------------------------------------------------------------------------
1 | # .\setup.ps1 으로 실행하세요
2 |
3 | # Set working directory to script location
4 | Set-Location $PSScriptRoot
5 |
6 | # Build DinD image
7 | docker build -t dind:latest -f ./host-container/Dockerfile ./host-container
8 |
9 | # Build quiz images
10 | docker build -t localhost/hello-world -f ./quiz-images/hello-world/Dockerfile ./quiz-images/hello-world
11 | docker build -t localhost/joke -f ./quiz-images/joke/Dockerfile ./quiz-images/joke
12 |
13 | # Start Registry container
14 | docker compose -f ./registry/docker-compose.yml up -d
15 |
16 | Start-Sleep -Seconds 5
17 |
18 | # Push images to Registry
19 | docker push localhost/hello-world
20 | docker push localhost/joke
--------------------------------------------------------------------------------
/sandbox/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | cd "$(dirname "$0")"
6 |
7 | # DinD 이미지 빌드
8 | docker build --tag dind:latest --file ./host-container/Dockerfile ./host-container
9 |
10 | # hello-world 이미지 빌드
11 | docker build --tag localhost/hello-world --file ./quiz-images/hello-world/Dockerfile ./quiz-images/hello-world
12 |
13 | # joke 이미지 빌드
14 | docker build --tag localhost/joke --file ./quiz-images/joke/Dockerfile ./quiz-images/joke
15 |
16 | # Registry 컨테이너 실행
17 | docker compose -f ./registry/docker-compose.yml up -d
18 |
19 | sleep 5
20 |
21 | # Registry에 퀴즈용 이미지 push
22 | docker push localhost/hello-world
23 | docker push localhost/joke
24 |
--------------------------------------------------------------------------------