Kurs Vue.js · Moduł 12: Testy i wdrożenie

Projekt: Mars Mission Launch - Complete Production App

7 min czytania
W tej lekcji7

Finalna aplikacja gotowa do startu! Twój kod leci na Marsa!

Przez pięć lekcji składałeś procedurę startową z osobnych elementów: testy komponentów, testy logiki, E2E, build i wdrożenie. Każdy działał w swojej lekcji, ale prawdziwy projekt potrzebuje ich wszystkich naraz, w jednym repozytorium, spiętych tak, żeby żaden krok nie zależał od pamięci operatora. Ten projekt to kompletna konfiguracja produkcyjna galerii - przejdziemy przez nią plik po pliku.

Konfiguracja Vite gotowa do startu

Plik vite.config.js zaczyna się od trzech wtyczek: vue() kompiluje komponenty, ViteImageOptimizer kompresuje obrazy, a visualizer rysuje raport rozmiarów pakietów i od razu otwiera go w przeglądarce:

1// vite.config.js - Production ready
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
5import { visualizer } from 'rollup-plugin-visualizer'
6
7export default defineConfig({
8  plugins: [
9    vue(),
10    ViteImageOptimizer({
11      jpg: { quality: 80 },
12      png: { quality: 80 },
13      webp: { quality: 80 }
14    }),
15    visualizer({
16      open: true,
17      gzipSize: true
18    })
19  ],

Wtyczki nie ruszają plików w src - pracują na tym, co wędruje do dist. Druga część pliku ustawia build: minifikator terser, podział na pakiety i port serwera deweloperskiego:

1  // vite.config.js - dalszy ciąg defineConfig
2  build: {
3    outDir: 'dist',
4    sourcemap: false,
5    minify: 'terser',
6
7    terserOptions: {
8      compress: {
9        drop_console: true,
10        drop_debugger: true
11      }
12    },
13
14    rolldownOptions: {
15      output: {
16        codeSplitting: {
17          groups: [
18            { name: 'vue-vendor', test: /node_modules[\\/](vue|@vue|vue-router|pinia)[\\/]/ },
19            { name: 'utils', test: /node_modules[\\/]@vueuse[\\/]/ }
20          ]
21        }
22      }
23    }
24  },
25
26  server: {
27    port: 3000,
28    open: true
29  }
30})

Opcje drop_console i drop_debugger w terserOptions usuwają z kodu produkcyjnego wszystkie wywołania console.log i instrukcje debugger; pliki źródłowe zostają nietknięte. Terser trzeba doinstalować (npm install -D terser), a domyślny minifikator Vite 8, Oxc, zrobi to samo opcją dropConsole w build.rolldownOptions.output.minify.compress. Podział opisuje codeSplitting: każda grupa ma nazwę pakietu i test, czyli wyrażenie regularne dopasowywane do ścieżki modułu, w którym [\\/] łapie ukośnik i ukośnik wsteczny ścieżek Windows. Starsze konfiguracje z rollupOptions.output.manualChunks w formie obiektu przestały działać - Vite 8 przerywa na nich build.

Skrypty w package.json

Plik package.json zbiera polecenia procedury startowej w jednym miejscu - każdy etap pipeline wywoła później jeden z tych skryptów:

1// package.json
2{
3  "name": "gallery-app",
4  "version": "1.0.0",
5  "scripts": {
6    "dev": "vite",
7    "build": "vite build",
8    "preview": "vite preview",
9    "test": "vitest",
10    "test:ui": "vitest --ui",
11    "test:coverage": "vitest --coverage",
12    "lint": "eslint . --ext .vue,.js,.ts",
13    "format": "prettier --write .",
14    "type-check": "vue-tsc --noEmit",
15    "deploy": "npm run build && vercel --prod"
16  }
17}

lint przegląda kod ESLintem, format porządkuje go Prettierem, a type-check uruchamia vue-tsc, czyli sprawdzanie typów TypeScript także w plikach .vue. deploy wysyła build poleceniem vercel --prod, równoważnym z vercel deploy --prod.

Lazy loading komponentów

Ciężki komponent, na przykład wykres telemetrii otwierany na żądanie, nie musi siedzieć w głównym pakiecie. Funkcja defineAsyncComponent z pakietu vue przyjmuje funkcję z dynamicznym import() i zwraca komponent pobierany przy pierwszym wyrenderowaniu:

1import { defineAsyncComponent } from 'vue'
2
3const TelemetryChart = defineAsyncComponent(() =>
4  import('./components/TelemetryChart.vue')
5)

Build wydzieli TelemetryChart.vue do osobnego pakietu, a w szablonie używasz go jak zwykłego komponentu. Trasom routera wystarczy samo () => import().

Pipeline CI/CD

Workflow dzieli pracę na trzy zadania (jobs). Pierwsze, test, rusza przy każdym pushu i pull requeście i przechodzi pełną kontrolę jakości:

1# .github/workflows/ci.yml
2name: CI/CD
3
4on:
5  push:
6    branches: [main, develop]
7  pull_request:
8    branches: [main]
9
10jobs:
11  test:
12    runs-on: ubuntu-latest
13
14    steps:
15      - uses: actions/checkout@v7
16
17      - name: Setup Node
18        uses: actions/setup-node@v7
19        with:
20          node-version: 24
21          cache: 'npm'
22
23      - name: Install dependencies
24        run: npm ci
25
26      - name: Lint
27        run: npm run lint
28
29      - name: Type check
30        run: npm run type-check
31
32      - name: Run tests
33        run: npm run test:coverage
34
35      - name: Upload coverage
36        uses: codecov/codecov-action@v7
37        with:
38          token: ${{ secrets.CODECOV_TOKEN }}

Kolejność jest celowa: najtańsze kontrole idą pierwsze. cache: 'npm' przechowuje paczki między uruchomieniami, a akcja Codecov wysyła raport pokrycia z tokenem z sekretów repozytorium. Zadanie build czeka na zielony test dzięki polu needs i zapisuje dist jako artefakt:

1  build:
2    runs-on: ubuntu-latest
3    needs: test
4
5    steps:
6      - uses: actions/checkout@v7
7
8      - name: Setup Node
9        uses: actions/setup-node@v7
10        with:
11          node-version: 24
12          cache: 'npm'
13
14      - name: Install dependencies
15        run: npm ci
16
17      - name: Build
18        run: npm run build
19
20      - name: Upload build artifacts
21        uses: actions/upload-artifact@v7
22        with:
23          name: dist
24          path: dist

Artefakt to plik, który GitHub przechowuje między zadaniami. Wersje v3 akcji artefaktów przestały działać 30 stycznia 2025 roku, dlatego stare workflow kończą się dziś błędem. Ostatnie zadanie wdraża aplikację wyłącznie z gałęzi main, co sprawdza warunek if:

1  deploy:
2    runs-on: ubuntu-latest
3    needs: build
4    if: github.ref == 'refs/heads/main'
5
6    steps:
7      - uses: actions/checkout@v7
8
9      - name: Download build artifacts
10        uses: actions/download-artifact@v8
11        with:
12          name: dist
13          path: dist
14
15      - name: Deploy to Vercel
16        uses: amondnet/vercel-action@v42
17        with:
18          vercel-token: ${{ secrets.VERCEL_TOKEN }}
19          vercel-org-id: ${{ secrets.ORG_ID }}
20          vercel-project-id: ${{ secrets.PROJECT_ID }}
21          vercel-args: '--prod'

Pull requesty przechodzą więc testy i build bez wdrożenia. Pobrany artefakt przyda się przy hostingu statycznym, bo Vercel domyślnie buduje projekt u siebie, z przesłanego kodu.

Kapsuła Docker

Wersja kontenerowa to Dockerfile z poprzedniej lekcji z komentarzami i kontrolą HEALTHCHECK, która co 30 sekund sprawdza, czy nginx odpowiada:

1# Dockerfile - Multi-stage build
2FROM node:24-alpine AS builder
3
4WORKDIR /app
5
6# Copy package files
7COPY package*.json ./
8
9# Install dependencies (the build needs devDependencies too)
10RUN npm ci
11
12# Copy source
13COPY . .
14
15# Build
16RUN npm run build
17
18# Production stage
19FROM nginx:alpine
20
21# Copy build
22COPY --from=builder /app/dist /usr/share/nginx/html
23
24# Copy nginx config
25COPY nginx.conf /etc/nginx/conf.d/default.conf
26
27# Health check
28HEALTHCHECK --interval=30s --timeout=3s \
29  CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
30
31EXPOSE 80
32
33CMD ["nginx", "-g", "daemon off;"]

Uważaj na częsty błąd: npm ci --only=production w etapie budowania pomija devDependencies, w których siedzi Vite, więc build by się nie udał. Do końcowego obrazu i tak trafia tylko dist.

Zoptymalizowany nginx

Konfiguracja serwera dokłada kompresję gzip, nagłówki bezpieczeństwa i osobne reguły cache:

1# nginx.conf - Optimized
2server {
3  listen 80;
4  server_name _;
5  root /usr/share/nginx/html;
6  index index.html;
7
8  # Gzip
9  gzip on;
10  gzip_vary on;
11  gzip_types text/plain text/css text/xml text/javascript
12             application/x-javascript application/xml+rss
13             application/javascript application/json;
14
15  # Security headers
16  add_header X-Frame-Options "SAMEORIGIN" always;
17  add_header X-Content-Type-Options "nosniff" always;
18  # Deprecated header: 0 disables the legacy XSS filter, use a Content-Security-Policy instead
19  add_header X-XSS-Protection "0" always;
20
21  # SPA routing
22  location / {
23    try_files $uri $uri/ /index.html;
24  }
25
26  # Cache static assets
27  location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
28    expires 1y;
29    add_header Cache-Control "public, immutable";
30  }
31
32  # Don't cache index.html
33  location = /index.html {
34    add_header Cache-Control "no-cache, must-revalidate";
35  }
36}

index.html nie może trafić do długiego cache, bo wskazuje aktualne nazwy pakietów. X-XSS-Protection jest przestarzały i MDN zaleca zamiast niego Content-Security-Policy, stąd wartość 0. Pułapka: add_header z poziomu server nie przechodzi do bloków location z własnym add_header, więc tam nagłówki bezpieczeństwa trzeba powtórzyć.

Monitorowanie i SEO

Po starcie patrzysz na przyrządy: profilowanie wydajności w Vue DevTools pokaże zbyt częste renderowanie, Lighthouse audyt ładowania, a metryki Web Vitals (LCP, INP, CLS) doświadczenia prawdziwych użytkowników. SPA wysyła botom wyszukiwarek pusty index.html, a treść dorysowuje JavaScript. Google i Bing radzą sobie z prostymi aplikacjami, ale nie czekają na dane pobierane asynchronicznie, więc pełny HTML da botom dopiero SSR albo SSG, najprościej z Nuxt - poznasz go w Laboratorium Kwantowym. Przed ręcznym startem zachowaj kolejność pipeline: lint i sprawdzenie typów, testy, npm run build, podgląd npm run preview, a na końcu vercel --prod.

GRATULACJE! Ukończyłeś szkolenie na Platformie Startowej NOVA LAB!

Twój kod przeszedł wszystkie testy przedstartowe i jest gotowy na misję marsjańską!

Teraz Twoja aplikacja:

  • Jest w pełni przetestowana (wszystkie systemy sprawdzone)
  • Zoptymalizowana pod kątem wydajności (gotowa na trudne warunki kosmiczne)
  • Gotowa do wdrożenia (procedura startowa zakończona)
  • Ma CI/CD pipeline (automatyczne systemy kontroli)
  • Działa w kontenerze Docker (hermetyczna kapsuła)

MISJA STARTOWA ZAKOŃCZONA SUKCESEM!

Moja rada: skopiuj ten zestaw plików do własnego repozytorium i dopasuj go do swojej aplikacji - to szkielet na każdy kolejny projekt Vue. Twój kod startuje na Marsa, a przed Tobą jeszcze trzy lokacje NOVA LAB; w Sali Holograficznej ożywisz interfejs animacjami.

Zapamiętaj: aplikacja jest gotowa do lotu dopiero wtedy, gdy testy, build i wdrożenie przechodzą automatycznie, bez udziału operatora.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, onMounted } from 'vue'
3
4// NOVA LAB - panel monitoringu
5const metrics = ref({
6  uptime: 99.9,
7  responseTime: 145,
8  errorRate: 0.02,
9  activeUsers: 1247,
10  requestsPerMinute: 3542,
11  cpuUsage: 45,
12  memoryUsage: 62,
13  diskUsage: 38
14})
15
16const alerts = ref([
17  { id: 1, level: 'warning', message: 'Wykryto wysokie zuzycie pamieci', time: '2 min temu' },
18  { id: 2, level: 'info', message: 'Wdrozenie zakonczone pomyslnie', time: '15 min temu' }
19])
20
21const performanceData = ref([
22  { time: '00:00', value: 120 },
23  { time: '04:00', value: 95 },
24  { time: '08:00', value: 180 },
25  { time: '12:00', value: 220 },
26  { time: '16:00', value: 195 },
27  { time: '20:00', value: 150 }
28])
29
30const healthStatus = computed(() => {
31  const { uptime, errorRate, responseTime } = metrics.value
32
33  if (uptime >= 99.5 && errorRate < 0.1 && responseTime < 200) {
34    return { status: 'healthy', label: 'Sprawny', color: '#00ff88' }
35  } else if (uptime >= 99 && errorRate < 0.5 && responseTime < 500) {
36    return { status: 'warning', label: 'Ostrzezenie', color: '#ffa500' }
37  } else {
38    return { status: 'critical', label: 'Krytyczny', color: '#ff0000' }
39  }
40})
41
42const performanceScore = computed(() => {
43  const avgResponseTime = metrics.value.responseTime
44  if (avgResponseTime < 100) return 'Doskonala'
45  if (avgResponseTime < 200) return 'Dobra'
46  if (avgResponseTime < 500) return 'Przecietna'
47  return 'Slaba'
48})
49
50function refreshMetrics() {
51  // Symulacja aktualizacji metryk
52  metrics.value.responseTime = Math.floor(Math.random() * 100) + 100
53  metrics.value.activeUsers = Math.floor(Math.random() * 500) + 1000
54  metrics.value.requestsPerMinute = Math.floor(Math.random() * 1000) + 3000
55  metrics.value.cpuUsage = Math.floor(Math.random() * 30) + 30
56  metrics.value.memoryUsage = Math.floor(Math.random() * 20) + 50
57}
58
59onMounted(() => {
60  // Automatyczne odswiezanie co 5 sekund
61  setInterval(refreshMetrics, 5000)
62})
63</script>
64
65<template>
66  <div class="monitoring-dashboard">
67    <div class="header">
68      <h1>Panel kontroli misji</h1>
69      <p>Monitoring NOVA LAB w czasie rzeczywistym</p>
70    </div>
71
72    <div class="health-status" :style="{ borderColor: healthStatus.color }">
73      <div class="status-icon" :style="{ color: healthStatus.color }">
74        {{ healthStatus.status === 'healthy' ? 'OK' : healthStatus.status === 'warning' ? '!' : 'X' }}
75      </div>
76      <div class="status-info">
77        <h2>Stan systemu: {{ healthStatus.label }}</h2>
78        <p>Dostepnosc: {{ metrics.uptime }}% | Wydajnosc: {{ performanceScore }}</p>
79      </div>
80    </div>
81
82    <div class="metrics-grid">
83      <div class="metric-card">
84        <div class="metric-icon">◆</div>
85        <div class="metric-value">{{ metrics.activeUsers.toLocaleString() }}</div>
86        <div class="metric-label">Aktywni uzytkownicy</div>
87      </div>
88
89      <div class="metric-card">
90        <div class="metric-icon">↯</div>
91        <div class="metric-value">{{ metrics.responseTime }}ms</div>
92        <div class="metric-label">Sredni czas odpowiedzi</div>
93      </div>
94
95      <div class="metric-card">
96        <div class="metric-icon">▲</div>
97        <div class="metric-value">{{ metrics.requestsPerMinute.toLocaleString() }}</div>
98        <div class="metric-label">Zapytania/min</div>
99      </div>
100
101      <div class="metric-card">
102        <div class="metric-icon">●</div>
103        <div class="metric-value">{{ metrics.errorRate }}%</div>
104        <div class="metric-label">Odsetek bledow</div>
105      </div>
106    </div>
107
108    <div class="resource-usage">
109      <h3>Zuzycie zasobow</h3>
110      <div class="resource-bars">
111        <div class="resource-item">
112          <div class="resource-header">
113            <span>CPU</span>
114            <span>{{ metrics.cpuUsage }}%</span>
115          </div>
116          <div class="resource-bar">
117            <div
118              class="resource-fill"
119              :style="{
120                width: metrics.cpuUsage + '%',
121                background: metrics.cpuUsage > 80 ? '#ff0000' : metrics.cpuUsage > 60 ? '#ffa500' : '#00ff88'
122              }"
123            ></div>
124          </div>
125        </div>
126
127        <div class="resource-item">
128          <div class="resource-header">
129            <span>Pamiec</span>
130            <span>{{ metrics.memoryUsage }}%</span>
131          </div>
132          <div class="resource-bar">
133            <div
134              class="resource-fill"
135              :style="{
136                width: metrics.memoryUsage + '%',
137                background: metrics.memoryUsage > 80 ? '#ff0000' : metrics.memoryUsage > 60 ? '#ffa500' : '#00ff88'
138              }"
139            ></div>
140          </div>
141        </div>
142
143        <div class="resource-item">
144          <div class="resource-header">
145            <span>Dysk</span>
146            <span>{{ metrics.diskUsage }}%</span>
147          </div>
148          <div class="resource-bar">
149            <div
150              class="resource-fill"
151              :style="{
152                width: metrics.diskUsage + '%',
153                background: metrics.diskUsage > 80 ? '#ff0000' : metrics.diskUsage > 60 ? '#ffa500' : '#00ff88'
154              }"
155            ></div>
156          </div>
157        </div>
158      </div>
159    </div>
160
161    <div class="alerts-panel">
162      <h3>Ostatnie alerty</h3>
163      <div class="alerts-list">
164        <div
165          v-for="alert in alerts"
166          :key="alert.id"
167          class="alert-item"
168          :class="alert.level"
169        >
170          <span class="alert-icon">
171            {{ alert.level === 'warning' ? '!' : alert.level === 'error' ? 'X' : 'ℹ' }}
172          </span>
173          <span class="alert-message">{{ alert.message }}</span>
174          <span class="alert-time">{{ alert.time }}</span>
175        </div>
176      </div>
177    </div>
178
179    <div class="actions">
180      <button @click="refreshMetrics" class="refresh-btn">
181        Odswiez metryki
182      </button>
183    </div>
184  </div>
185</template>
186
187<style scoped>
188.monitoring-dashboard {
189  background: #0a0e27;
190  color: #00ff88;
191  padding: 2rem;
192  min-height: 100vh;
193  font-family: 'Courier New', monospace;
194}
195
196.header {
197  text-align: center;
198  margin-bottom: 2rem;
199  border-bottom: 2px solid #00b4d8;
200  padding-bottom: 1rem;
201}
202
203.header h1 {
204  color: #00ff88;
205  text-shadow: 0 0 10px #00ff88;
206  margin: 0;
207}
208
209.health-status {
210  display: flex;
211  align-items: center;
212  gap: 1.5rem;
213  background: rgba(0, 180, 216, 0.1);
214  border: 3px solid;
215  border-radius: 12px;
216  padding: 1.5rem;
217  margin-bottom: 2rem;
218}
219
220.status-icon {
221  font-size: 3rem;
222}
223
224.status-info h2 {
225  margin: 0 0 0.5rem 0;
226  color: #fff;
227}
228
229.status-info p {
230  margin: 0;
231  color: #00b4d8;
232}
233
234.metrics-grid {
235  display: grid;
236  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
237  gap: 1.5rem;
238  margin-bottom: 2rem;
239}
240
241.metric-card {
242  background: rgba(0, 180, 216, 0.1);
243  border: 2px solid #00b4d8;
244  border-radius: 8px;
245  padding: 1.5rem;
246  text-align: center;
247  transition: transform 0.3s;
248}
249
250.metric-card:hover {
251  transform: translateY(-5px);
252  box-shadow: 0 5px 20px rgba(0, 180, 216, 0.3);
253}
254
255.metric-icon {
256  font-size: 2.5rem;
257  margin-bottom: 0.5rem;
258}
259
260.metric-value {
261  font-size: 2rem;
262  color: #00ff88;
263  font-weight: bold;
264  margin-bottom: 0.5rem;
265}
266
267.metric-label {
268  color: #00b4d8;
269  font-size: 0.9rem;
270}
271
272.resource-usage, .alerts-panel {
273  background: rgba(0, 180, 216, 0.1);
274  border: 1px solid #00b4d8;
275  border-radius: 8px;
276  padding: 1.5rem;
277  margin-bottom: 1.5rem;
278}
279
280.resource-usage h3, .alerts-panel h3 {
281  color: #00b4d8;
282  margin-top: 0;
283}
284
285.resource-bars {
286  display: flex;
287  flex-direction: column;
288  gap: 1rem;
289}
290
291.resource-item {
292  display: flex;
293  flex-direction: column;
294  gap: 0.5rem;
295}
296
297.resource-header {
298  display: flex;
299  justify-content: space-between;
300  color: #fff;
301  font-size: 0.9rem;
302}
303
304.resource-bar {
305  height: 20px;
306  background: rgba(0, 255, 136, 0.1);
307  border-radius: 10px;
308  overflow: hidden;
309  border: 1px solid #00b4d8;
310}
311
312.resource-fill {
313  height: 100%;
314  transition: width 0.5s, background 0.5s;
315  border-radius: 10px;
316}
317
318.alerts-list {
319  display: flex;
320  flex-direction: column;
321  gap: 0.75rem;
322}
323
324.alert-item {
325  display: flex;
326  align-items: center;
327  gap: 1rem;
328  padding: 1rem;
329  border-radius: 8px;
330  border-left: 4px solid;
331}
332
333.alert-item.warning {
334  background: rgba(255, 165, 0, 0.1);
335  border-color: #ffa500;
336}
337
338.alert-item.error {
339  background: rgba(255, 0, 0, 0.1);
340  border-color: #ff0000;
341}
342
343.alert-item.info {
344  background: rgba(0, 180, 216, 0.1);
345  border-color: #00b4d8;
346}
347
348.alert-icon {
349  font-size: 1.5rem;
350}
351
352.alert-message {
353  flex: 1;
354  color: #fff;
355}
356
357.alert-time {
358  color: #00b4d8;
359  font-size: 0.85rem;
360}
361
362.actions {
363  text-align: center;
364}
365
366.refresh-btn {
367  padding: 1rem 2rem;
368  background: rgba(0, 180, 216, 0.2);
369  border: 2px solid #00b4d8;
370  color: #00b4d8;
371  font-size: 1rem;
372  font-family: 'Courier New', monospace;
373  border-radius: 8px;
374  cursor: pointer;
375  transition: all 0.3s;
376}
377
378.refresh-btn:hover {
379  background: #00b4d8;
380  color: #0a0e27;
381  box-shadow: 0 0 20px #00b4d8;
382}
383</style>

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jak zaimplementować lazy loading komponentu w Vue 3?

  2. 2. Jakie narzędzia pomagają monitorować performance aplikacji Vue?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię lazy loading komponentu:

  • Edytor kodu

    Skonfiguruj pełną vite.config.js z: vue plugin, image optimizer, visualizer, build options z podziałem na pakiety (w Vite 8 przez codeSplitting zamiast manualChunks).

  • Układanie w pionie

    Uporządkuj kroki przygotowania do produkcji od pierwszego do ostatniego:

  • Klikanie w kolejności

    Ułóż komendę wdrożenia produkcyjnego na Vercel:

  • Układanie w pionie

    Uporządkuj kroki GitHub Actions CI/CD workflow:

  • Klikanie w kolejności

    Ułóż komendę wdrożenia produkcyjnego na Netlify:

  • Układanie w poziomie

    Ułóż dyrektywę nginx dla SPA routing:

  • Klikanie w kolejności

    Ułóż komendę uruchomienia kontenera Docker z mapowaniem portów:

Przydatne artykuły