├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.local.yml ├── docker-compose.yml ├── docker-port-viewer ├── .gitignore ├── package-lock.json └── package.json ├── nginx.conf ├── package.json ├── public ├── Thumbs.db ├── favicon.ico ├── favicon.svg └── index.html ├── src ├── App.tsx ├── index.tsx └── types.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | /.pnp 4 | .pnp.js 5 | 6 | # Testing 7 | /coverage 8 | 9 | # Production 10 | /build 11 | /dist 12 | 13 | # Docker 14 | .dockerignore 15 | docker-compose.override.yml 16 | 17 | # Misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env 24 | *.env 25 | 26 | # Logs 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | logs 31 | *.log 32 | 33 | # Editor directories and files 34 | .idea/ 35 | .vscode/ 36 | *.swp 37 | *.swo 38 | *~ 39 | 40 | # TypeScript 41 | *.tsbuildinfo 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | .env.local 61 | .env.development.local 62 | .env.test.local 63 | .env.production.local 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | .parcel-cache 68 | 69 | # Next.js build output 70 | .next 71 | out 72 | 73 | # Nuxt.js build / generate output 74 | .nuxt 75 | dist 76 | 77 | # Gatsby files 78 | .cache/ 79 | # Comment in the public line in if your project uses Gatsby and not Next.js 80 | # https://nextjs.org/blog/next-9-1#public-directory-support 81 | # public 82 | 83 | # vuepress build output 84 | .vuepress/dist 85 | 86 | # Serverless directories 87 | .serverless/ 88 | 89 | # FuseBox cache 90 | .fusebox/ 91 | 92 | # DynamoDB Local files 93 | .dynamodb/ 94 | 95 | # TernJS port file 96 | .tern-port 97 | 98 | # Stores VSCode versions used for testing VSCode extensions 99 | .vscode-test 100 | 101 | # yarn v2 102 | .yarn/cache 103 | .yarn/unplugged 104 | .yarn/build-state.yml 105 | .yarn/install-state.gz 106 | .pnp.* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy package files 7 | COPY package*.json ./ 8 | 9 | # Install dependencies including webpack and its dependencies 10 | RUN npm install && \ 11 | npm install --save-dev webpack webpack-cli webpack-dev-server typescript ts-loader html-webpack-plugin style-loader css-loader 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Production stage 20 | FROM nginx:alpine 21 | 22 | # Copy built assets from builder stage 23 | COPY --from=builder /app/dist /usr/share/nginx/html 24 | 25 | # Copy nginx configuration 26 | COPY nginx.conf /etc/nginx/conf.d/default.conf 27 | 28 | # Expose port 80 29 | EXPOSE 80 30 | 31 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Port Viewer 2 | 3 | A modern web application for viewing and interacting with Docker container ports. Built with TypeScript, React, and Material-UI, it provides a user-friendly interface to manage and access your Docker containers. 4 | 5 | ![Docker Port Viewer Interface](https://github.com/user-attachments/assets/0c331919-3cc7-4ef0-91b4-5f80fd9adb6e) 6 | 7 | ## Features 8 | 9 | - View all running Docker containers and their exposed ports 10 | - Search containers by name 11 | - Sort containers by name or creation date 12 | - View container details including: 13 | - Container name 14 | - Image name 15 | - Status 16 | - Start time 17 | - Exposed ports 18 | - Direct access to container web interfaces through: 19 | - Built-in iframe viewer 20 | - New tab option 21 | - Customizable hostname for accessing containers 22 | - Secure Docker socket proxy integration 23 | - Responsive Material-UI design 24 | 25 | ## Prerequisites 26 | 27 | - Docker and Docker Compose installed 28 | - Access to the Docker socket 29 | 30 | ## Quick Start 31 | 32 | ### Using Pre-built Image 33 | 34 | 1. Pull the pre-built image: 35 | ```bash 36 | docker pull hollowpnt/docker-port-viewer:latest 37 | ``` 38 | 39 | 2. Run using Docker Compose: 40 | ```bash 41 | docker-compose up -d 42 | ``` 43 | 44 | The application will be available at `http://localhost:3003` 45 | 46 | ### Local Development 47 | 48 | 1. Clone the repository: 49 | ```bash 50 | git clone 51 | cd docker-port-viewer 52 | ``` 53 | 54 | 2. Install dependencies: 55 | ```bash 56 | npm install 57 | ``` 58 | 59 | 3. Start the development server: 60 | ```bash 61 | npm start 62 | ``` 63 | 64 | 4. For production build: 65 | ```bash 66 | npm run build 67 | ``` 68 | 69 | ### Building Docker Image 70 | 71 | To build the Docker image locally: 72 | 73 | ```bash 74 | docker build -t docker-port-viewer:latest . 75 | ``` 76 | 77 | For cross-platform builds (e.g., building for Linux from macOS): 78 | ```bash 79 | docker build --platform linux/amd64 -t docker-port-viewer:latest . 80 | ``` 81 | 82 | ## Architecture 83 | 84 | The application consists of two main components: 85 | 86 | 1. **Frontend**: A React application built with TypeScript and Material-UI 87 | 2. **Backend**: Nginx server that proxies requests to the Docker socket 88 | 89 | ### Security 90 | 91 | The application uses a Docker socket proxy (`tecnativa/docker-socket-proxy`) to ensure secure access to the Docker API. The proxy is configured to: 92 | - Allow only container listing 93 | - Disable POST, PUT, and DELETE operations 94 | - Mount the Docker socket as read-only 95 | 96 | ## Configuration 97 | 98 | The application can be configured through the following environment variables: 99 | 100 | - `PORT`: The port on which the application runs (default: 3003) 101 | - `DOCKER_SOCKET`: Path to the Docker socket (default: /var/run/docker.sock) 102 | 103 | ## Contributing 104 | 105 | Contributions are welcome! Please feel free to submit a Pull Request. 106 | 107 | ## License 108 | 109 | ISC 110 | -------------------------------------------------------------------------------- /docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | # Docker socket proxy for security 5 | docker-socket: 6 | image: tecnativa/docker-socket-proxy 7 | environment: 8 | - CONTAINERS=1 9 | - POST=0 10 | - PUT=0 11 | - DELETE=0 12 | volumes: 13 | - /var/run/docker.sock:/var/run/docker.sock:ro 14 | 15 | # Main application 16 | app: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile 20 | ports: 21 | - "3003:80" 22 | depends_on: 23 | - docker-socket -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Docker socket proxy for security 3 | docker-socket: 4 | image: tecnativa/docker-socket-proxy 5 | container_name: docker-socket-proxy 6 | environment: 7 | - CONTAINERS=1 8 | - POST=0 9 | - PUT=0 10 | - DELETE=0 11 | volumes: 12 | - /var/run/docker.sock:/var/run/docker.sock:ro 13 | 14 | # Main application 15 | docker-port-viewer: 16 | image: hollowpnt/docker-port-viewer:latest 17 | container_name: docker-port-viewer 18 | ports: 19 | - "3003:80" -------------------------------------------------------------------------------- /docker-port-viewer/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | docker-port-viewer/ 3 | /.pnp 4 | .pnp.js 5 | 6 | # Testing 7 | /coverage 8 | 9 | # Production 10 | /build 11 | /dist 12 | 13 | # Docker 14 | .dockerignore 15 | docker-compose.override.yml 16 | 17 | # Misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env 24 | *.env 25 | 26 | # Logs 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | logs 31 | *.log 32 | 33 | # Editor directories and files 34 | .idea/ 35 | .vscode/ 36 | *.swp 37 | *.swo 38 | *~ 39 | 40 | # TypeScript 41 | *.tsbuildinfo 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | .env.local 61 | .env.development.local 62 | .env.test.local 63 | .env.production.local 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | .parcel-cache 68 | 69 | # Next.js build output 70 | .next 71 | out 72 | 73 | # Nuxt.js build / generate output 74 | .nuxt 75 | dist 76 | 77 | # Gatsby files 78 | .cache/ 79 | # Comment in the public line in if your project uses Gatsby and not Next.js 80 | # https://nextjs.org/blog/next-9-1#public-directory-support 81 | # public 82 | 83 | # vuepress build output 84 | .vuepress/dist 85 | 86 | # Serverless directories 87 | .serverless/ 88 | 89 | # FuseBox cache 90 | .fusebox/ 91 | 92 | # DynamoDB Local files 93 | .dynamodb/ 94 | 95 | # TernJS port file 96 | .tern-port 97 | 98 | # Stores VSCode versions used for testing VSCode extensions 99 | .vscode-test 100 | 101 | # yarn v2 102 | .yarn/cache 103 | .yarn/unplugged 104 | .yarn/build-state.yml 105 | .yarn/install-state.gz 106 | .pnp.* -------------------------------------------------------------------------------- /docker-port-viewer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-port-viewer", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@emotion/styled": "^11.14.0", 15 | "@mui/icons-material": "^7.0.2", 16 | "@mui/material": "^7.0.2", 17 | "axios": "^1.8.4", 18 | "react": "^19.1.0", 19 | "react-dom": "^19.1.0" 20 | }, 21 | "devDependencies": { 22 | "@types/axios": "^0.9.36", 23 | "@types/node": "^22.14.0", 24 | "@types/react": "^19.1.0", 25 | "@types/react-dom": "^19.1.2", 26 | "css-loader": "^7.1.2", 27 | "html-webpack-plugin": "^5.6.3", 28 | "style-loader": "^4.0.0", 29 | "ts-loader": "^9.5.2", 30 | "typescript": "^5.8.3", 31 | "webpack": "^5.99.5", 32 | "webpack-cli": "^6.0.1", 33 | "webpack-dev-server": "^5.2.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | root /usr/share/nginx/html; 5 | index index.html; 6 | 7 | # Serve static files 8 | location / { 9 | try_files $uri $uri/ /index.html; 10 | } 11 | 12 | # Proxy Docker socket requests 13 | location /v1.41/ { 14 | proxy_pass http://docker-socket:2375/v1.41/; 15 | proxy_http_version 1.1; 16 | proxy_set_header Upgrade $http_upgrade; 17 | proxy_set_header Connection 'upgrade'; 18 | proxy_set_header Host $host; 19 | proxy_cache_bypass $http_upgrade; 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-port-viewer", 3 | "version": "1.0.0", 4 | "description": "A TypeScript application to view Docker container ports", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack serve --mode development --open", 8 | "build": "webpack --mode production", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": ["docker", "ports", "viewer"], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@emotion/react": "^11.11.3", 16 | "@emotion/styled": "^11.11.0", 17 | "@mui/icons-material": "^5.15.10", 18 | "@mui/material": "^5.15.10", 19 | "axios": "^1.6.7", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "typescript": "^5.3.3" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^20.11.19", 26 | "@types/react": "^18.2.55", 27 | "@types/react-dom": "^18.2.19", 28 | "css-loader": "^6.10.0", 29 | "html-webpack-plugin": "^5.6.0", 30 | "style-loader": "^3.3.4", 31 | "ts-loader": "^9.5.1", 32 | "webpack": "^5.90.1", 33 | "webpack-cli": "^5.1.4", 34 | "webpack-dev-server": "^4.15.1" 35 | } 36 | } -------------------------------------------------------------------------------- /public/Thumbs.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hollowpnt92/docker-port-viewer/2044d169aeee75664b3da4be5c8972920ab59b90/public/Thumbs.db -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hollowpnt92/docker-port-viewer/2044d169aeee75664b3da4be5c8972920ab59b90/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Docker Port Viewer 11 | 12 | 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, ChangeEvent } from 'react'; 2 | import { 3 | Container, 4 | Typography, 5 | Box, 6 | TextField, 7 | Card, 8 | CardContent, 9 | List, 10 | ListItem, 11 | ListItemText, 12 | Link, 13 | CircularProgress, 14 | Alert, 15 | InputAdornment, 16 | Select, 17 | MenuItem, 18 | FormControl, 19 | InputLabel, 20 | SelectChangeEvent, 21 | Grid, 22 | Paper, 23 | IconButton, 24 | Menu, 25 | } from '@mui/material'; 26 | import SearchIcon from '@mui/icons-material/Search'; 27 | import SortIcon from '@mui/icons-material/Sort'; 28 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 29 | import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; 30 | import RefreshIcon from '@mui/icons-material/Refresh'; 31 | import MoreVertIcon from '@mui/icons-material/MoreVert'; 32 | import OpenInNewIcon from '@mui/icons-material/OpenInNew'; 33 | import WebIcon from '@mui/icons-material/Web'; 34 | import axios from 'axios'; 35 | import { Container as DockerContainer, Port } from './types'; 36 | 37 | const HOSTNAME_STORAGE_KEY = 'docker-port-viewer-hostname'; 38 | const SORT_OPTION_STORAGE_KEY = 'docker-port-viewer-sort-option'; 39 | 40 | type SortOption = 'name-asc' | 'name-desc' | 'created-asc' | 'created-desc'; 41 | 42 | const App: React.FC = () => { 43 | const [containers, setContainers] = useState([]); 44 | const [filteredContainers, setFilteredContainers] = useState([]); 45 | const [hostname, setHostname] = useState(() => { 46 | return localStorage.getItem(HOSTNAME_STORAGE_KEY) || 'localhost'; 47 | }); 48 | const [searchTerm, setSearchTerm] = useState(''); 49 | const [sortOption, setSortOption] = useState(() => { 50 | return (localStorage.getItem(SORT_OPTION_STORAGE_KEY) as SortOption) || 'name-asc'; 51 | }); 52 | const [loading, setLoading] = useState(true); 53 | const [error, setError] = useState(null); 54 | const [currentUrl, setCurrentUrl] = useState(null); 55 | const [iframeKey, setIframeKey] = useState(0); 56 | const [iframeHistory, setIframeHistory] = useState([]); 57 | const [historyIndex, setHistoryIndex] = useState(-1); 58 | const [anchorEl, setAnchorEl] = useState(null); 59 | const [selectedUrl, setSelectedUrl] = useState(null); 60 | 61 | useEffect(() => { 62 | fetchContainers(); 63 | }, []); 64 | 65 | useEffect(() => { 66 | let filtered = containers; 67 | 68 | if (searchTerm.trim() !== '') { 69 | filtered = containers.filter((container: DockerContainer) => 70 | container.Names.some((name: string) => 71 | name.toLowerCase().includes(searchTerm.toLowerCase()) 72 | ) 73 | ); 74 | } 75 | 76 | filtered.sort((a: DockerContainer, b: DockerContainer) => { 77 | switch (sortOption) { 78 | case 'name-asc': 79 | return a.Names[0].localeCompare(b.Names[0]); 80 | case 'name-desc': 81 | return b.Names[0].localeCompare(a.Names[0]); 82 | case 'created-asc': 83 | return Number(b.Created) - Number(a.Created); 84 | case 'created-desc': 85 | return Number(a.Created) - Number(b.Created); 86 | default: 87 | return 0; 88 | } 89 | }); 90 | 91 | setFilteredContainers(filtered); 92 | }, [searchTerm, containers, sortOption]); 93 | 94 | const handleHostnameChange = (e: ChangeEvent) => { 95 | const newHostname = e.target.value; 96 | setHostname(newHostname); 97 | localStorage.setItem(HOSTNAME_STORAGE_KEY, newHostname); 98 | }; 99 | 100 | const handleSortChange = (e: SelectChangeEvent) => { 101 | const newSortOption = e.target.value as SortOption; 102 | setSortOption(newSortOption); 103 | localStorage.setItem(SORT_OPTION_STORAGE_KEY, newSortOption); 104 | }; 105 | 106 | const fetchContainers = async () => { 107 | try { 108 | setLoading(true); 109 | setError(null); 110 | const response = await axios.get('/v1.41/containers/json', { 111 | socketPath: '/var/run/docker.sock' 112 | }); 113 | setContainers(response.data); 114 | setFilteredContainers(response.data); 115 | } catch (err) { 116 | setError('Failed to fetch container information. Make sure the Docker socket is accessible.'); 117 | console.error('Error fetching containers:', err); 118 | } finally { 119 | setLoading(false); 120 | } 121 | }; 122 | 123 | const generateLink = (port: number): string => { 124 | return `http://${hostname}:${port}`; 125 | }; 126 | 127 | const handleLinkClick = (e: React.MouseEvent, url: string) => { 128 | e.preventDefault(); 129 | setCurrentUrl(url); 130 | setIframeKey(prev => prev + 1); 131 | setIframeHistory(prev => [...prev.slice(0, historyIndex + 1), url]); 132 | setHistoryIndex(prev => prev + 1); 133 | }; 134 | 135 | const handleBack = () => { 136 | if (historyIndex > 0) { 137 | const newIndex = historyIndex - 1; 138 | setHistoryIndex(newIndex); 139 | setCurrentUrl(iframeHistory[newIndex]); 140 | setIframeKey(prev => prev + 1); 141 | } 142 | }; 143 | 144 | const handleForward = () => { 145 | if (historyIndex < iframeHistory.length - 1) { 146 | const newIndex = historyIndex + 1; 147 | setHistoryIndex(newIndex); 148 | setCurrentUrl(iframeHistory[newIndex]); 149 | setIframeKey(prev => prev + 1); 150 | } 151 | }; 152 | 153 | const handleRefresh = () => { 154 | setIframeKey(prev => prev + 1); 155 | }; 156 | 157 | const handleMenuClick = (event: React.MouseEvent, url: string) => { 158 | setAnchorEl(event.currentTarget); 159 | setSelectedUrl(url); 160 | }; 161 | 162 | const handleMenuClose = () => { 163 | setAnchorEl(null); 164 | setSelectedUrl(null); 165 | }; 166 | 167 | const handleOpenInIframe = () => { 168 | if (selectedUrl) { 169 | handleLinkClick(new MouseEvent('click') as any, selectedUrl); 170 | } 171 | handleMenuClose(); 172 | }; 173 | 174 | const handleOpenInNewTab = () => { 175 | if (selectedUrl) { 176 | window.open(selectedUrl, '_blank', 'noopener,noreferrer'); 177 | } 178 | handleMenuClose(); 179 | }; 180 | 181 | return ( 182 | 183 | 184 | 185 | Docker Port Viewer 186 | 187 | 188 | 189 | 190 | 197 | 198 | 199 | ) => setSearchTerm(e.target.value)} 204 | size="small" 205 | InputProps={{ 206 | startAdornment: ( 207 | 208 | 209 | 210 | ), 211 | }} 212 | /> 213 | 214 | 215 | 216 | Sort By 217 | 232 | 233 | 234 | 235 | 236 | {error && ( 237 | 238 | {error} 239 | 240 | )} 241 | 242 | 243 | 244 | {/* Left Column - Container List */} 245 | 254 | {loading ? ( 255 | 256 | 257 | 258 | ) : ( 259 | 260 | {filteredContainers.map((container: DockerContainer) => ( 261 | 270 | 273 | {container.Names[0].replace(/^\//, '')} 274 | 275 | } 276 | secondary={ 277 | 278 | 279 | {container.Image} 280 | 281 | 282 | Status: {container.State} 283 | 284 | 285 | Started: {container.State === 'running' ? new Date(Number(container.Created) * 1000).toLocaleString() : 'Not started'} 286 | 287 | 288 | {container.Ports 289 | .filter((port, index, self) => 290 | index === self.findIndex(p => 291 | p.PublicPort === port.PublicPort && 292 | p.PrivatePort === port.PrivatePort 293 | ) 294 | ) 295 | .map((port: Port, index: number) => ( 296 | 297 | 301 | handleLinkClick(e, generateLink(port.PublicPort))} 304 | sx={{ fontSize: '0.875rem', flex: 1 }} 305 | > 306 | {generateLink(port.PublicPort)} 307 | 308 | handleMenuClick(e, generateLink(port.PublicPort))} 311 | > 312 | 313 | 314 | 315 | ) : ( 316 | 317 | Private Port: {port.PrivatePort} 318 | 319 | ) 320 | } 321 | secondary={ 322 | 323 | {port.Type} - Internal Port: {port.PrivatePort} 324 | 325 | } 326 | /> 327 | 328 | ))} 329 | 330 | 331 | } 332 | /> 333 | 334 | ))} 335 | {filteredContainers.length === 0 && searchTerm && ( 336 | 337 | 338 | No containers found matching "{searchTerm}" 339 | 340 | 341 | )} 342 | 343 | )} 344 | 345 | 346 | {/* Right Column - Iframe */} 347 | 356 | 363 | 368 | 369 | 370 | = iframeHistory.length - 1} 373 | size="small" 374 | > 375 | 376 | 377 | 378 | 379 | 380 | 390 | {currentUrl || 'No URL selected'} 391 | 392 | 393 | 394 | {currentUrl ? ( 395 |