mirror of
https://github.com/sailor-sh/CK-X.git
synced 2026-02-14 09:29:53 +00:00
Initial Commit
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# Node.js dependencies and logs
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.npm/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Docker related
|
||||
.docker/
|
||||
docker-compose.override.yml
|
||||
|
||||
# Kubernetes secrets (in production, consider using sealed secrets or other secure methods)
|
||||
# k8s/secrets/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
tmp/
|
||||
out/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# IDE and editors
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.history/
|
||||
*.sublime*
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
*.code-workspace
|
||||
|
||||
# OS generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
.temp/
|
||||
.cache/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Miscellaneous
|
||||
.vagrant/
|
||||
.terraform/
|
||||
terraform.tfstate
|
||||
terraform.tfstate.backup
|
||||
.terragrunt-cache/
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# CK-X Simulator 🚀
|
||||
|
||||
A powerful Kubernetes certification practice environment that provides a realistic exam-like experience for kubernetess exam preparation.
|
||||
|
||||
## Major Features
|
||||
|
||||
- **Realistic exam environment** with web-based interface and remote desktop support
|
||||
- Comprehensive practice labs for **CKAD, CKA, CKS**, and other Kubernetes certifications
|
||||
- **Smart evaluation system** with real-time solution verification
|
||||
- **Docker-based deployment** for easy setup and consistent environment
|
||||
- **Timed exam mode** with real exam-like conditions and countdown timer
|
||||
|
||||
## Installation
|
||||
|
||||
#### Linux & macOS
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/nishanb/ck-x/main/scripts/install.sh)
|
||||
```
|
||||
|
||||
#### Windows
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/nishanb/ck-x/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
For detailed installation instructions, please refer to our [Deployment Guide](scripts/COMPOSE-DEPLOY.md).
|
||||
|
||||
## Community & Support
|
||||
|
||||
- Join our [Telegram Community](https://t.me/ckxdev) for discussions and support
|
||||
- Feature requests and pull requests are welcome
|
||||
|
||||
## Adding New Labs
|
||||
|
||||
Check our [Lab Creation Guide](docs/how-to-add-new-labs.md) for instructions on adding new labs.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Whether you want to:
|
||||
- Add new practice labs
|
||||
- Improve existing features
|
||||
- Fix bugs
|
||||
- Enhance documentation
|
||||
|
||||
## Buy Me a Coffee ☕
|
||||
|
||||
If you find CK-X Simulator helpful, consider [buying me a coffee](https://buymeacoffee.com/nishan.b) to support the project.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
CK-X is an independent tool, not affiliated with CNCF, Linux Foundation, or PSI. We do not guarantee exam success. Please read our [Privacy Policy](PRIVACY_POLICY.md) and [Terms of Service](TERMS_OF_SERVICE.md) for more details about data collection, usage, and limitations.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [DIND](https://github.com/earthly/dind)
|
||||
- [KIND](https://github.com/kubernetes-sigs/kind)
|
||||
- [Node](https://nodejs.org/en)
|
||||
- [Nginx](https://nginx.org/)
|
||||
- [ConSol-Vnc](https://github.com/ConSol/docker-headless-vnc-container/)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
39
TERMS_OF_SERVICE.md
Normal file
39
TERMS_OF_SERVICE.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Terms of Service
|
||||
|
||||
**Effective Date:** [Insert Date]
|
||||
|
||||
## 1. Acceptance of Terms
|
||||
By accessing or using CK-X, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use CK-X.
|
||||
|
||||
## 2. No Affiliation
|
||||
CK-X is **not affiliated with, endorsed by, or associated with the Cloud Native Computing Foundation (CNCF), the Linux Foundation, or PSI.** Any references to Kubernetes, CKAD, CKA, or CKS are for descriptive purposes only, and all trademarks belong to their respective owners.
|
||||
|
||||
## 3. Use of CK-X
|
||||
- CK-X is provided for educational and training purposes only.
|
||||
- You may use CK-X to practice Kubernetes concepts and improve your skills.
|
||||
- You may not use CK-X for any illegal, malicious, or unauthorized purposes.
|
||||
|
||||
## 4. No Guarantees
|
||||
- CK-X does **not** guarantee success in CKAD, CKA, or CKS certification exams.
|
||||
- The content provided is for **educational purposes only** and does not replicate actual exam questions.
|
||||
|
||||
## 5. Data Collection & Privacy
|
||||
- CK-X collects minimal data necessary for improving the simulator.
|
||||
- We **do not** sell, share, or misuse user data.
|
||||
- See our [Privacy Policy](PRIVACY_POLICY.md) for more details.
|
||||
|
||||
## 6. Contributions
|
||||
- Contributions to CK-X are welcome.
|
||||
- By submitting code or content, you agree that it can be used and distributed under the CK-X project’s open-source license.
|
||||
|
||||
## 7. Security Disclaimer
|
||||
- CK-X is designed for educational use only and should **not** be deployed in production environments.
|
||||
- Running CK-X on personal or enterprise systems is **at your own risk**.
|
||||
- We do **not** take responsibility for security vulnerabilities, misconfigurations, or unintended consequences of using CK-X.
|
||||
|
||||
## 8. Limitation of Liability
|
||||
- CK-X is provided "as is" without any warranties.
|
||||
- The maintainers and contributors are not liable for any losses, damages, or exam failures resulting from the use of CK-X.
|
||||
|
||||
## 9. Changes to Terms
|
||||
We reserve the right to update these Terms of Service at any time. Continued use of CK-X after changes implies acceptance of the revised terms.
|
||||
57
app/.dockerignore
Normal file
57
app/.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
compose*.yaml
|
||||
.dockerignore
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Other unnecessary files
|
||||
*.gz
|
||||
*.zip
|
||||
*.tar
|
||||
*.rar
|
||||
tmp/
|
||||
temp/
|
||||
22
app/Dockerfile
Normal file
22
app/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:16-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy the application files
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Ensure the public directory exists
|
||||
RUN mkdir -p /app/public
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
19
app/config/config.js
Normal file
19
app/config/config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Configuration module for the application
|
||||
* Centralizes all environment variables and configuration settings
|
||||
*/
|
||||
|
||||
// Server configuration
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// VNC service configuration
|
||||
const VNC_SERVICE_HOST = process.env.VNC_SERVICE_HOST || 'remote-desktop-service';
|
||||
const VNC_SERVICE_PORT = process.env.VNC_SERVICE_PORT || 6901;
|
||||
const VNC_PASSWORD = process.env.VNC_PASSWORD || 'bakku-the-wizard'; // Default password
|
||||
|
||||
module.exports = {
|
||||
PORT,
|
||||
VNC_SERVICE_HOST,
|
||||
VNC_SERVICE_PORT,
|
||||
VNC_PASSWORD
|
||||
};
|
||||
7
app/init.sh
Normal file
7
app/init.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy the HTML file to Nginx's directory
|
||||
cp /app/public/* /usr/share/nginx/html/
|
||||
|
||||
# Keep the container running
|
||||
tail -f /dev/null
|
||||
22
app/middleware/errorHandler.js
Normal file
22
app/middleware/errorHandler.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Error Handler Middleware
|
||||
* Centralized error handling for the application
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Global error handler middleware
|
||||
* @param {Error} err - The error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function errorHandler(err, req, res, next) {
|
||||
console.error('Server error:', err);
|
||||
|
||||
// Send a user-friendly error page
|
||||
res.status(500).sendFile(path.join(__dirname, '..', 'public', '50x.html'));
|
||||
}
|
||||
|
||||
module.exports = errorHandler;
|
||||
91
app/middleware/proxy.js
Normal file
91
app/middleware/proxy.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Proxy middleware module
|
||||
* Sets up the proxies for VNC connections
|
||||
*/
|
||||
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* Creates the VNC proxy configuration object
|
||||
* @returns {Object} Proxy configuration
|
||||
*/
|
||||
function createVncProxyConfig() {
|
||||
return {
|
||||
target: `http://${config.VNC_SERVICE_HOST}:${config.VNC_SERVICE_PORT}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
pathRewrite: {
|
||||
'^/vnc-proxy': ''
|
||||
},
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
// Log HTTP requests being proxied
|
||||
console.log(`Proxying HTTP request to VNC server: ${req.url}`);
|
||||
},
|
||||
onProxyReqWs: (proxyReq, req, socket, options, head) => {
|
||||
// Log WebSocket connections
|
||||
console.log(`WebSocket connection established to VNC server: ${req.url}`);
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// Log the responses from VNC server
|
||||
console.log(`Received response from VNC server for: ${req.url}`);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
console.error(`Proxy error: ${err.message}`);
|
||||
if (res && res.writeHead) {
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end(`Proxy error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up VNC proxy middleware on the Express app
|
||||
* @param {Object} app - Express application
|
||||
*/
|
||||
function setupProxies(app) {
|
||||
const vncProxyConfig = createVncProxyConfig();
|
||||
|
||||
// Middleware to enhance VNC URLs with authentication if needed
|
||||
app.use('/vnc-proxy', (req, res, next) => {
|
||||
// Check if the URL already has a password parameter
|
||||
if (!req.query.password) {
|
||||
// If no password provided, add default password
|
||||
console.log('Adding default VNC password to request');
|
||||
const separator = req.url.includes('?') ? '&' : '?';
|
||||
req.url = `${req.url}${separator}password=${config.VNC_PASSWORD}`;
|
||||
}
|
||||
next();
|
||||
}, createProxyMiddleware(vncProxyConfig));
|
||||
|
||||
// Direct WebSocket proxy to handle the websockify endpoint
|
||||
app.use('/websockify', createProxyMiddleware({
|
||||
...vncProxyConfig,
|
||||
pathRewrite: {
|
||||
'^/websockify': '/websockify'
|
||||
},
|
||||
ws: true,
|
||||
onProxyReqWs: (proxyReq, req, socket, options, head) => {
|
||||
// Log WebSocket connections to websockify
|
||||
console.log(`WebSocket connection to websockify established: ${req.url}`);
|
||||
|
||||
// Add additional headers if needed
|
||||
proxyReq.setHeader('Origin', `http://${config.VNC_SERVICE_HOST}:${config.VNC_SERVICE_PORT}`);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
console.error(`Websockify proxy error: ${err.message}`);
|
||||
if (res && res.writeHead) {
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end(`Websockify proxy error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = setupProxies;
|
||||
21
app/package.json
Normal file
21
app/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "killer-sh-clone-webapp",
|
||||
"version": "1.0.0",
|
||||
"description": "Web application for killer.sh clone with VNC access",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"http-proxy-middleware": "^2.0.7",
|
||||
"socket.io": "^4.7.2",
|
||||
"ssh2": "^1.14.0",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.15"
|
||||
}
|
||||
}
|
||||
44
app/public/50x.html
Normal file
44
app/public/50x.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Error - CKAD Simulator</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 5rem;
|
||||
color: #dc3545;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h1 class="mb-4">Server Error</h1>
|
||||
<p class="lead mb-4">Sorry, something went wrong on our server. Please try again later.</p>
|
||||
<a href="/" class="btn btn-primary">Go back to home page</a>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
65
app/public/answers.html
Normal file
65
app/public/answers.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exam Answers - CK-X Simulator</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="stylesheet" href="/css/results.css">
|
||||
<link rel="stylesheet" href="/css/answers.css">
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<!-- Marked.js for Markdown rendering -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<!-- Highlight.js for code syntax highlighting -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<main class="answers-container">
|
||||
<!-- Loading state -->
|
||||
<div id="pageLoader" class="page-loader">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading exam answers...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="errorMessage" class="error-message" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p id="errorText">An error occurred while loading the answers.</p>
|
||||
<button id="retryButton" class="button">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Answers content -->
|
||||
<div id="answersContent" class="answers-content" style="display: none;">
|
||||
<div class="answers-header">
|
||||
<h2>Exam Answers</h2>
|
||||
<div class="answers-actions">
|
||||
<button id="backToResultsBtn" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i> Back to Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="examInfoBox" class="exam-info-box">
|
||||
<p id="examId">Exam ID: Loading...</p>
|
||||
</div>
|
||||
<div id="markdownContent" class="markdown-content">
|
||||
<!-- Markdown content will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p style="text-align: center;">© <span id="currentYear"></span> CK-X Simulator.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/js/answers.js"></script>
|
||||
<script>
|
||||
// Set the current year for copyright
|
||||
document.getElementById('currentYear').textContent = new Date().getFullYear();
|
||||
</script>
|
||||
<script data-name="BMC-Widget" data-cfasync="false" src="https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js" data-id="nishan.b" data-description="Support me on Buy me a coffee!" data-message="CK-X helped you prep? A coffee helps it grow !!" data-color="#5F7FFF" data-position="Right" data-x_margin="18" data-y_margin="18"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
app/public/assets/bmc.svg
Normal file
52
app/public/assets/bmc.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 600 450" style="enable-background:new 0 0 600 450;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#010202;}
|
||||
.st1{fill:#FFDD06;}
|
||||
</style>
|
||||
<path class="st0" d="M390.1,137.3l-0.2-0.1l-0.5-0.2C389.5,137.2,389.8,137.3,390.1,137.3z"/>
|
||||
<path class="st0" d="M393.4,160.9l-0.3,0.1L393.4,160.9z"/>
|
||||
<path class="st0" d="M390.1,137.2C390.1,137.2,390.1,137.2,390.1,137.2C390,137.2,390,137.2,390.1,137.2
|
||||
C390.1,137.2,390.1,137.2,390.1,137.2z"/>
|
||||
<path class="st0" d="M393.1,160.7l0.4-0.2l0.1-0.1l0.1-0.1C393.5,160.4,393.3,160.6,393.1,160.7z"/>
|
||||
<path class="st0" d="M390.7,137.8l-0.4-0.4l-0.3-0.1C390.2,137.5,390.4,137.7,390.7,137.8z"/>
|
||||
<path class="st0" d="M297,366.2c-0.3,0.1-0.6,0.3-0.8,0.6l0.2-0.2C296.7,366.4,296.9,366.3,297,366.2z"/>
|
||||
<path class="st0" d="M351.5,355.4c0-0.3-0.2-0.3-0.1,0.9c0-0.1,0-0.2,0.1-0.3C351.4,355.9,351.4,355.7,351.5,355.4z"/>
|
||||
<path class="st0" d="M345.8,366.2c-0.3,0.1-0.6,0.3-0.8,0.6l0.2-0.2C345.5,366.4,345.7,366.3,345.8,366.2z"/>
|
||||
<path class="st0" d="M258.7,368.7c-0.2-0.2-0.5-0.3-0.8-0.4c0.2,0.1,0.5,0.2,0.6,0.3L258.7,368.7z"/>
|
||||
<path class="st0" d="M250.2,360.5c0-0.3-0.1-0.7-0.3-1C250,359.8,250.1,360.1,250.2,360.5L250.2,360.5z"/>
|
||||
<path class="st1" d="M308,212.8c-11.8,5.1-25.3,10.8-42.7,10.8c-7.3,0-14.5-1-21.5-3l12,123.6c0.4,5.2,2.8,10,6.6,13.5
|
||||
c3.8,3.5,8.8,5.5,14,5.5c0,0,17.1,0.9,22.8,0.9c6.1,0,24.5-0.9,24.5-0.9c5.2,0,10.2-1.9,14-5.5c3.8-3.5,6.2-8.3,6.6-13.5l12.9-136.5
|
||||
c-5.8-2-11.6-3.3-18.1-3.3C327.7,204.4,318.6,208.3,308,212.8z"/>
|
||||
<path class="st0" d="M206.5,160.2l0.2,0.2l0.1,0.1C206.8,160.3,206.7,160.2,206.5,160.2z"/>
|
||||
<path class="st0" d="M412.8,148.7l-1.8-9.1c-1.6-8.2-5.3-16-13.7-18.9c-2.7-0.9-5.8-1.4-7.8-3.3c-2.1-2-2.7-5-3.2-7.8
|
||||
c-0.9-5.2-1.7-10.4-2.6-15.6c-0.8-4.5-1.4-9.5-3.4-13.5c-2.7-5.5-8.2-8.7-13.7-10.8c-2.8-1-5.7-1.9-8.6-2.7
|
||||
c-13.7-3.6-28.1-4.9-42.2-5.7c-16.9-0.9-33.9-0.7-50.8,0.8c-12.6,1.1-25.8,2.5-37.7,6.9c-4.4,1.6-8.9,3.5-12.2,6.9
|
||||
c-4.1,4.1-5.4,10.6-2.4,15.7c2.1,3.7,5.7,6.3,9.5,8c4.9,2.2,10.1,3.9,15.4,5c14.8,3.3,30,4.5,45.1,5.1c16.7,0.7,33.4,0.1,50.1-1.6
|
||||
c4.1-0.5,8.2-1,12.3-1.6c4.8-0.7,7.9-7.1,6.5-11.4c-1.7-5.3-6.3-7.3-11.4-6.5c-0.8,0.1-1.5,0.2-2.3,0.3l-0.5,0.1
|
||||
c-1.8,0.2-3.5,0.4-5.3,0.6c-3.6,0.4-7.2,0.7-10.9,1c-8.1,0.6-16.3,0.8-24.5,0.8c-8,0-16-0.2-24-0.8c-3.7-0.2-7.3-0.5-10.9-0.9
|
||||
c-1.7-0.2-3.3-0.4-4.9-0.6l-1.6-0.2l-0.3,0l-1.6-0.2c-3.3-0.5-6.6-1.1-9.9-1.8c-0.3-0.1-0.6-0.3-0.8-0.5s-0.3-0.6-0.3-0.9
|
||||
c0-0.3,0.1-0.7,0.3-0.9s0.5-0.4,0.8-0.5h0.1c2.8-0.6,5.7-1.1,8.6-1.6c1-0.2,1.9-0.3,2.9-0.4h0c1.8-0.1,3.6-0.4,5.4-0.7
|
||||
c15.6-1.6,31.3-2.2,47-1.7c7.6,0.2,15.2,0.7,22.8,1.4c1.6,0.2,3.3,0.3,4.9,0.5c0.6,0.1,1.2,0.2,1.9,0.2l1.3,0.2
|
||||
c3.7,0.5,7.3,1.2,11,2c5.4,1.2,12.3,1.6,14.7,7.4c0.8,1.9,1.1,3.9,1.5,5.9l0.5,2.5c0,0,0,0.1,0,0.1c1.3,5.9,2.5,11.8,3.8,17.7
|
||||
c0.1,0.4,0.1,0.9,0,1.3c-0.1,0.4-0.3,0.9-0.5,1.2c-0.3,0.4-0.6,0.7-1,0.9c-0.4,0.2-0.8,0.4-1.2,0.4h0l-0.8,0.1l-0.8,0.1
|
||||
c-2.4,0.3-4.9,0.6-7.3,0.9c-4.8,0.5-9.6,1-14.4,1.4c-9.6,0.8-19.1,1.3-28.7,1.6c-4.9,0.1-9.8,0.2-14.7,0.2
|
||||
c-19.5,0-38.9-1.1-58.2-3.4c-2.1-0.2-4.2-0.5-6.3-0.8c1.6,0.2-1.2-0.2-1.7-0.2c-1.3-0.2-2.7-0.4-4-0.6c-4.5-0.7-8.9-1.5-13.4-2.2
|
||||
c-5.4-0.9-10.5-0.4-15.4,2.2c-4,2.2-7.2,5.5-9.3,9.6c-2.1,4.3-2.7,9.1-3.7,13.7c-0.9,4.7-2.4,9.7-1.8,14.5
|
||||
c1.2,10.3,8.4,18.7,18.8,20.6c9.8,1.8,19.6,3.2,29.5,4.4c38.7,4.7,77.9,5.3,116.7,1.7c3.2-0.3,6.3-0.6,9.5-1c1-0.1,2,0,2.9,0.3
|
||||
c0.9,0.3,1.8,0.9,2.5,1.6c0.7,0.7,1.2,1.5,1.6,2.5c0.3,0.9,0.5,1.9,0.4,2.9l-1,9.6c-2,19.3-4,38.6-5.9,58
|
||||
c-2.1,20.3-4.1,40.6-6.2,60.9c-0.6,5.7-1.2,11.4-1.8,17.1c-0.6,5.6-0.6,11.4-1.7,17c-1.7,8.7-7.6,14.1-16.2,16.1
|
||||
c-7.9,1.8-16,2.7-24.1,2.8c-9,0-18-0.3-27-0.3c-9.6,0.1-21.4-0.8-28.8-8c-6.5-6.3-7.4-16.1-8.3-24.6c-1.2-11.2-2.4-22.5-3.5-33.7
|
||||
l-6.5-62.5l-4.2-40.5c-0.1-0.7-0.1-1.3-0.2-2c-0.5-4.8-3.9-9.5-9.3-9.3c-4.6,0.2-9.8,4.1-9.3,9.3l3.1,30l6.5,62
|
||||
c1.8,17.6,3.7,35.2,5.5,52.9c0.4,3.4,0.7,6.8,1.1,10.1c2,18.5,16.1,28.4,33.6,31.2c10.2,1.6,20.6,2,31,2.1
|
||||
c13.3,0.2,26.7,0.7,39.7-1.7c19.3-3.5,33.8-16.4,35.9-36.5c0.6-5.8,1.2-11.6,1.8-17.3c2-19.1,3.9-38.2,5.9-57.4l6.4-62.5l2.9-28.6
|
||||
c0.1-1.4,0.7-2.8,1.7-3.8s2.2-1.8,3.6-2c5.5-1.1,10.8-2.9,14.7-7.1C413.8,166.2,415,157.5,412.8,148.7z M205,154.9
|
||||
c0.1,0-0.1,0.7-0.1,1C204.8,155.4,204.8,154.9,205,154.9z M205.5,159c0,0,0.2,0.1,0.3,0.4C205.6,159.2,205.5,159,205.5,159
|
||||
L205.5,159z M206,159.7C206.2,160.1,206.3,160.3,206,159.7L206,159.7z M207.1,160.6L207.1,160.6
|
||||
C207.1,160.6,207.2,160.6,207.1,160.6C207.1,160.6,207.1,160.6,207.1,160.6L207.1,160.6z M392.5,159.3c-2,1.9-5,2.8-7.9,3.2
|
||||
c-33.1,4.9-66.8,7.4-100.3,6.3c-24-0.8-47.7-3.5-71.5-6.8c-2.3-0.3-4.8-0.8-6.4-2.5c-3-3.2-1.5-9.7-0.7-13.7
|
||||
c0.7-3.6,2.1-8.4,6.4-8.9c6.6-0.8,14.4,2,20.9,3c7.9,1.2,15.9,2.2,23.8,2.9c34,3.1,68.7,2.6,102.5-1.9c6.2-0.8,12.3-1.8,18.5-2.9
|
||||
c5.5-1,11.5-2.8,14.8,2.8c2.3,3.9,2.6,9,2.2,13.4C394.8,156.2,393.9,158,392.5,159.3L392.5,159.3z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
107
app/public/css/answers.css
Normal file
107
app/public/css/answers.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* Answers Page Styles */
|
||||
|
||||
.answers-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.answers-content {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.answers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.answers-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.exam-info-box {
|
||||
background-color: #f0f8ff;
|
||||
border-left: 4px solid #3498db;
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.exam-info-box p {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Enhanced code block styling for better readability */
|
||||
.markdown-content pre {
|
||||
padding: 16px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Make tables more readable */
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-content thead {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even) {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.answers-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.answers-actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
484
app/public/css/exam.css
Normal file
484
app/public/css/exam.css
Normal file
@@ -0,0 +1,484 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #333;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
overflow: hidden; /* Prevent body scrolling */
|
||||
}
|
||||
.header {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #444;
|
||||
flex-shrink: 0; /* Prevent header from shrinking */
|
||||
}
|
||||
.header-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px; /* Space between title and timer */
|
||||
}
|
||||
.header-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.timer {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
.btn-custom {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.35rem 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: calc(100vh - 49px); /* Subtract header height */
|
||||
max-height: calc(100vh - 49px);
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent scrollbars during resize */
|
||||
}
|
||||
.question-panel {
|
||||
width: 30%;
|
||||
min-width: 200px;
|
||||
max-width: 70%; /* Prevent panel from taking too much space */
|
||||
background-color: white;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.05s ease; /* Smoother transitions when not dragging */
|
||||
will-change: width; /* Optimize for animations */
|
||||
}
|
||||
.vnc-panel {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 300px; /* Ensure VNC panel has enough space */
|
||||
overflow: hidden; /* Prevent overflow issues */
|
||||
max-height: 100%;
|
||||
}
|
||||
.question-nav {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.nav-arrow {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-arrow-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.question-select {
|
||||
flex: 1;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.question-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
transition: opacity 0.2s ease; /* Add smooth transition */
|
||||
max-height: calc(100vh - 95px); /* Header + Question Nav */
|
||||
}
|
||||
.info-box {
|
||||
background-color: #e3f2fd;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.info-icon {
|
||||
color: #0d6efd;
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.highlight {
|
||||
color: #d63384;
|
||||
font-weight: 500;
|
||||
}
|
||||
.code {
|
||||
font-family: monospace;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.terminal-iframe {
|
||||
flex: 1;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
max-height: 100%;
|
||||
}
|
||||
.terminal-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.terminal-controls:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.terminal-controls .btn {
|
||||
backdrop-filter: blur(4px);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
.loader {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
ul.requirements {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
ul.requirements li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.connection-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.connection-status.show {
|
||||
opacity: 1;
|
||||
}
|
||||
.panel-divider {
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background-color: #ddd;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-divider:hover, .panel-divider.dragging {
|
||||
background-color: #1e88e5;
|
||||
}
|
||||
.panel-divider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 30px;
|
||||
background-color: #999;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.panel-divider:hover::after, .panel-divider.dragging::after {
|
||||
background-color: #fff;
|
||||
}
|
||||
/* Divider hint and tooltip */
|
||||
.divider-hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none; /* Allow events to pass through */
|
||||
z-index: 11;
|
||||
width: 100px;
|
||||
}
|
||||
.panel-divider:hover .divider-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
.divider-arrows {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.divider-tooltip {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
}
|
||||
/* Prevent text selection during dragging */
|
||||
.no-select {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
/* Add a class for content transitions */
|
||||
.content-fade {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* Question transition indicators */
|
||||
.question-transition {
|
||||
position: relative;
|
||||
}
|
||||
.question-transition::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: #1e88e5;
|
||||
animation: pulseIndicator 1s ease-in-out;
|
||||
}
|
||||
@keyframes pulseIndicator {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
/* Inline code highlighting for text in single quotes */
|
||||
.inline-code {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 3px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 4px;
|
||||
color: #d63384;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.inline-code:hover {
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Improved spacing for question content */
|
||||
.question-body {
|
||||
padding: 10px 0;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.question-body p {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.question-body ul,
|
||||
.question-body ol {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.question-body li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.question-body .info-box {
|
||||
background-color: #f5f9ff;
|
||||
border-left: 4px solid #3498db;
|
||||
padding: 12px 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.question-body .requirements {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.ssh-terminal-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background-color: #1E1E1E;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
max-height: 100%;
|
||||
width: 100%; /* Ensure full width */
|
||||
max-width: 100%; /* Prevent overflow */
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Remove Terminal window bar */
|
||||
.ssh-terminal-container::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove Window title */
|
||||
.ssh-terminal-container::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove Terminal window controls (macOS style) */
|
||||
.ssh-terminal-container .terminal-controls::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1E1E1E;
|
||||
color: #F8F8F8;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
max-height: 100%;
|
||||
max-width: 100%; /* Prevent overflow */
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.terminal::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Add to existing terminal-container styles */
|
||||
.terminal-container, .ssh-terminal-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Make question action buttons stick to bottom */
|
||||
.action-buttons-container {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #ddd;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.timer-warning {
|
||||
background-color: rgba(220, 53, 69, 0.25);
|
||||
color: #ffc107;
|
||||
animation: pulseTimer 1.5s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulseTimer {
|
||||
from { background-color: rgba(220, 53, 69, 0.25); }
|
||||
to { background-color: rgba(220, 53, 69, 0.5); }
|
||||
}
|
||||
|
||||
/* Toast container customization */
|
||||
.toast-container {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Toast customization */
|
||||
.toast {
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
opacity: 1 !important; /* Make sure toasts are fully visible */
|
||||
}
|
||||
|
||||
.toast-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.toast.bg-warning {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.toast.bg-danger {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.review-mode-badge {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
101
app/public/css/feedback.css
Normal file
101
app/public/css/feedback.css
Normal file
@@ -0,0 +1,101 @@
|
||||
.feedback-prompt {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #5F7FFF;
|
||||
border-radius: 4px;
|
||||
padding: 12px 15px;
|
||||
margin: 20px 0;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feedback-prompt:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feedback-prompt a {
|
||||
color: #5F7FFF;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feedback-prompt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Toast notification styles */
|
||||
.toast-notification {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
color: #5F7FFF;
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.toast-message p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 5px 12px;
|
||||
background: #5F7FFF;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.toast-button:hover {
|
||||
background: #4967e6;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
582
app/public/css/index.css
Normal file
582
app/public/css/index.css
Normal file
@@ -0,0 +1,582 @@
|
||||
:root {
|
||||
--primary-color: #4f46e5;
|
||||
--primary-dark: #3730a3;
|
||||
--primary-light: #818cf8;
|
||||
--dark-color: #111827;
|
||||
--light-color: #f9fafb;
|
||||
--gray-color: #6b7280;
|
||||
--success-color: #10b981;
|
||||
--border-radius: 12px;
|
||||
--box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--font-sans: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
padding: 18px 0;
|
||||
background-color: rgba(17, 24, 39, 0.95) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar.scrolled {
|
||||
background-color: rgba(17, 24, 39, 0.98) !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
color: white !important;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
margin: 0 5px;
|
||||
padding: 8px 16px !important;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: white !important;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
background-color: var(--primary-color);
|
||||
background-image: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
color: white;
|
||||
padding: 8rem 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-top: 76px; /* Height of navbar plus some spacing */
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='white' fill-opacity='0.1' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: fadeInUp 1s ease-out;
|
||||
}
|
||||
|
||||
.hero-main-content {
|
||||
margin-bottom: 80px; /* Add space at the bottom to separate from scroll indicator */
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.display-4 {
|
||||
font-weight: 800 !important;
|
||||
margin-bottom: 2.5rem !important; /* Increased spacing */
|
||||
font-size: 3.75rem;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
background: linear-gradient(to right, #ffffff, #e0e7ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 3.5rem !important; /* Increased spacing */
|
||||
opacity: 0.95;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
#features {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: white;
|
||||
padding-top: 6rem;
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.features-wrapper {
|
||||
background-color: white;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
padding: 4rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 800;
|
||||
color: var(--dark-color);
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: -0.025em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 4px;
|
||||
width: 60px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--gray-color);
|
||||
font-size: 1.1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Feature cards */
|
||||
#features .row {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#features .col-lg-4,
|
||||
#features .col-md-6 {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: none;
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
transition: var(--transition);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1.25rem;
|
||||
background-color: #eef4ff;
|
||||
border-radius: 12px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-icon-container {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.feature-card h4 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--dark-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--gray-color);
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.8rem 2.5rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50px;
|
||||
transition: var(--transition);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background-color: white;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Buy Me Coffee Button */
|
||||
.bmc-navbar {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
padding: 8px 15px !important;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50px !important;
|
||||
margin-left: 10px;
|
||||
transition: var(--transition);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.bmc-navbar:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bmc-navbar svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Loader */
|
||||
.loader {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-width: 0.25em;
|
||||
}
|
||||
|
||||
.loader-message {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media (max-width: 992px) {
|
||||
.features-wrapper {
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.features-wrapper {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.display-4 {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.7rem 1.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.feature-icon-container {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Background Effect */
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Start Exam Button */
|
||||
.start-exam-btn {
|
||||
background-color: white;
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 700;
|
||||
padding: 0.9rem 3rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 50px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: var(--transition);
|
||||
display: inline-block;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.start-exam-btn:hover {
|
||||
background-color: white !important;
|
||||
color: var(--primary-color) !important;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Add these styles for the modal */
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header .modal-title {
|
||||
font-weight: 700;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.25);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eef4ff;
|
||||
border-color: #d1e0ff;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Button styles for modal */
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background-color: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Exam status badge */
|
||||
.exam-status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-evaluating {
|
||||
background-color: #f0ad4e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-evaluated {
|
||||
background-color: #5cb85c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* View Results button visibility */
|
||||
.view-results-btn-container {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Add these styles to the existing index.css file */
|
||||
|
||||
.loading-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
/* Renamed classes to avoid conflict with Bootstrap */
|
||||
.custom-progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-progress {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #4CAF50;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin: 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Exam meta styles that were added dynamically in JavaScript */
|
||||
.exam-meta {
|
||||
color: #4a5568;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exam-meta-container {
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.exam-details {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Full height hero section */
|
||||
.full-height {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Scroll indicator - animation removed */
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
/* animation: bounce 2s infinite; - removed */
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scroll-indicator p {
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.scroll-indicator i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Position the features section after the full-height hero */
|
||||
#features {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: white;
|
||||
padding-top: 6rem;
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
506
app/public/css/results.css
Normal file
506
app/public/css/results.css
Normal file
@@ -0,0 +1,506 @@
|
||||
/* Results Page Styles */
|
||||
|
||||
.results-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.page-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #3498db;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Animation for spinner */
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #fff3f3;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ffcccc;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
font-size: 48px;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.score-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.score-display h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.score-box {
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
padding: 10px 30px;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.rank-high {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rank-medium {
|
||||
background-color: #f39c12;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rank-low {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.questions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
background-color: #f2f4f8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
background-color: #e9ecef;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.question-score {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.verification-items {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.verification-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.verification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.verification-description {
|
||||
flex-grow: 1;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.verification-status {
|
||||
font-size: 24px;
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.status-failure {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.results-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.score-box, .rank-badge {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blue header styling */
|
||||
.blue-header {
|
||||
background-color: #1d82e2;
|
||||
color: white;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.blue-header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.blue-header .logo h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.blue-header .nav-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blue-header .nav-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Results actions at top */
|
||||
.results-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 20px 0 30px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.results-actions .btn {
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.results-actions .btn i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.results-actions .btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Add styles for answers content */
|
||||
.answers-content {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.answers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.answers-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 3px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-content table th,
|
||||
.markdown-content table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-content table tr {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
}
|
||||
|
||||
.markdown-content table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h4 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #e74c3c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Ensure the danger button is prominently red */
|
||||
.btn-danger {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
/* Animation for modal fade-in */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
55
app/public/css/styles.css
Normal file
55
app/public/css/styles.css
Normal file
@@ -0,0 +1,55 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
background-color: #1e88e5;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
.vnc-container {
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#vnc-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.btn {
|
||||
background-color: #1e88e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
273
app/public/exam.html
Normal file
273
app/public/exam.html
Normal file
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CK-X Simulator - Exam</title>
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- xterm.js CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/css/exam.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loader -->
|
||||
<div class="loader" id="pageLoader">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container for notifications -->
|
||||
<div class="toast-container position-fixed top-0 end-0 pt-5 pr-3" id="toastContainer">
|
||||
<!-- Toasts will be added here dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Exam End Modal -->
|
||||
<div class="modal fade" id="examEndModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="examEndModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="examEndModalLabel">Exam Time Complete</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Time's up! ⏳ Your exam has ended. </strong>
|
||||
</div>
|
||||
<p>We're now evaluating your results.</p>
|
||||
<p>Your environment session will remain available for review until you choose to end it.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" id="viewResultsBtn">View Results</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Exam Modal -->
|
||||
<div class="modal fade" id="startExamModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="startExamModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="startExamModalLabel">Ready to Begin Your Exam</h5>
|
||||
</div>
|
||||
|
||||
<!-- New Exam Content (default) -->
|
||||
<div class="modal-body" id="newExamContent">
|
||||
<div class="alert alert-success mb-3">
|
||||
<strong>Important:</strong> Please treat this as a real exam. Focus on completing tasks accurately and efficiently as you would in a certification exam.
|
||||
</div>
|
||||
<p>When you click "Start Exam", the following will happen:</p>
|
||||
<ul>
|
||||
<li>Your exam timer will begin counting down.</li>
|
||||
<li>You will be connected to the Kubernetes environment.</li>
|
||||
<li>The application will enter fullscreen mode</li>
|
||||
<li>When you click on the <span class="inline-code">highlighted</span> content, it will be copied to the clipboard.</li> </ul>
|
||||
<p class="mb-0">Are you ready to start?</p>
|
||||
</div>
|
||||
|
||||
<!-- Exam In Progress Content (initially hidden) -->
|
||||
<div class="modal-body" id="examInProgressContent" style="display: none;">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Note:</strong> You already have an exam in progress.
|
||||
</div>
|
||||
<p>Your previous session will be restored, including your remaining time.</p>
|
||||
<p>You can continue where you left off.</p>
|
||||
</div>
|
||||
|
||||
<!-- Exam Completed Content (initially hidden) -->
|
||||
<div class="modal-body" id="examCompletedContent" style="display: none;">
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Note:</strong> You've already completed this exam session.
|
||||
</div>
|
||||
<p>Your session is still available. You can continue to view the environment, but your answers have already been submitted.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" id="startExamBtn">Start Exam</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-title-section">
|
||||
<div class="header-title">CK-X Simulator</div>
|
||||
<div class="timer" id="examTimer">120:00</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn-custom dropdown-toggle" type="button" id="examInterfaceDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Exam Interface
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="examInterfaceDropdown">
|
||||
<li><a class="dropdown-item" href="#" id="resizeTerminalBtn">Resize Terminal</a></li>
|
||||
<li><a class="dropdown-item" href="#" id="fullscreenBtn">Toggle Fullscreen</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" id="toggleViewBtn">Switch to Terminal</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="https://github.com/nishanb/CKAD-X">Help</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn-custom dropdown-toggle" type="button" id="examControlsDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Exam Controls
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="examControlsDropdown">
|
||||
<li><a class="dropdown-item" href="#" id="endExamBtnDropdown">End Exam</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" id="terminateSessionBtn">Terminate Session</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn-custom dropdown-toggle" type="button" id="examInfoDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Exam Information
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="examInfoDropdown">
|
||||
<li><a class="dropdown-item" href="https://docs.linuxfoundation.org/tc-docs/certification" target="_blank">Exam Instructions</a></li>
|
||||
<li><a class="dropdown-item" href="https://kubernetes.io/docs/home/" target="_blank">Kubernetes Documentation</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="main-container" id="mainContainer">
|
||||
<!-- Question Panel (Left) -->
|
||||
<div class="question-panel" id="questionPanel">
|
||||
<!-- Question Navigation -->
|
||||
<div class="question-nav">
|
||||
<button class="nav-arrow" id="prevBtn"><</button>
|
||||
<div class="dropdown question-select">
|
||||
<button class="btn btn-light w-100 dropdown-toggle" type="button" id="questionDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Question 1
|
||||
</button>
|
||||
<ul class="dropdown-menu w-100" aria-labelledby="questionDropdown" id="questionDropdownMenu">
|
||||
<li><a class="dropdown-item" href="#" data-question="1">Question 1</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-question="2">Question 2</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-question="3">Question 3</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-question="4">Question 4</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-question="5">Question 5</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="nav-arrow" id="nextBtn">></button>
|
||||
</div>
|
||||
|
||||
<!-- Question Content -->
|
||||
<div class="question-content" id="questionContent">
|
||||
<!-- Question will be loaded here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resizable Divider -->
|
||||
<div class="panel-divider" id="panelDivider">
|
||||
<div class="divider-hint">
|
||||
<span class="divider-arrows">⟷</span>
|
||||
<span class="divider-tooltip">Drag to resize</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VNC Panel (Right) -->
|
||||
<div class="vnc-panel" id="vncPanel">
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-controls">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" id="reconnectVncBtn" title="Reconnect to session">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-repeat" viewBox="0 0 16 16">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="fullscreenVncBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-fullscreen" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<iframe id="vnc-frame" class="terminal-iframe" src="about:blank"></iframe>
|
||||
<div id="connectionStatus" class="connection-status">Connecting to Session...</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Terminal Container (initially hidden) -->
|
||||
<div class="ssh-terminal-container" id="sshTerminalContainer" style="display: none;">
|
||||
<div class="terminal-controls">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="fullscreenTerminalBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-fullscreen" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="terminal" class="terminal"></div>
|
||||
<div id="sshConnectionStatus" class="connection-status">Connecting to SSH...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">End Exam Confirmation</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to end the exam? This action cannot be undone.</p>
|
||||
<div class="alert alert-success mt-2">
|
||||
<p class="mb-1"><strong>What happens next:</strong></p>
|
||||
<ul class="mb-0">
|
||||
<li>Your submissions will be evaluated.</li>
|
||||
<li>You will be redirected to the results page.</li>
|
||||
<li>Your session will remain intact until you choose to terminate it.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" id="confirmEndBtn">End Exam</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminate Session Modal -->
|
||||
<div class="modal fade" id="terminateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Terminate Session Warning</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">Warning!</h6>
|
||||
<p>Terminating this session will:</p>
|
||||
<ul>
|
||||
<li>Immediately clean up all environment resources</li>
|
||||
<li>End your exam without evaluating your answers</li>
|
||||
</ul>
|
||||
<p>This action <strong>cannot</strong> be undone.</p>
|
||||
</div>
|
||||
<p>Would you like to proceed with terminating this session?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmTerminateBtn">Terminate Session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- xterm.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<!-- Socket.io -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.7.2/dist/socket.io.min.js"></script>
|
||||
<!-- Custom JS -->
|
||||
<script type="module" src="/js/exam.js"></script>
|
||||
<script src="/js/panel-resizer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
233
app/public/index.html
Normal file
233
app/public/index.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>CK-X | Kubernetes Certification Simulator</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/css/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loader -->
|
||||
<div class="loader" id="pageLoader">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="loader-message" id="loaderMessage">Lab is getting ready...</div>
|
||||
</div>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">CK-X</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item me-3 view-results-btn-container" style="display: none;">
|
||||
<a class="nav-link" href="#" id="viewPastResultsBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-clipboard-check" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||
</svg>
|
||||
View Result
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/nishanb/CKAD-X" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section - Full Height -->
|
||||
<section class="hero-section full-height d-flex align-items-center text-center position-relative">
|
||||
<div class="container hero-content">
|
||||
<h1 class="display-4 mb-4">Kubernetes Certification Exam Simulator</h1>
|
||||
<p class="lead mb-5">Practice in a realistic environment that mirrors the actual CKA, CKAD, and other Kubernetes certification exams</p>
|
||||
<a href="#" class="btn btn-light btn-lg start-exam-btn" id="startExamBtn">START EXAM</a>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator">
|
||||
<p>SCROLL TO EXPLORE</p>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features">
|
||||
<div class="container">
|
||||
<div class="features-wrapper">
|
||||
<div class="features-header">
|
||||
<h2 class="section-title">Key Features</h2>
|
||||
<p class="section-subtitle">Everything you need to prepare for your Kubernetes certification exams</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26 4H6C4.89543 4 4 4.89543 4 6V22C4 23.1046 4.89543 24 6 24H26C27.1046 24 28 23.1046 28 22V6C28 4.89543 27.1046 4 26 4Z" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 28H20" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 24V28" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 16L15 18L19 14" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>Real Exam Environment</h4>
|
||||
<p>Practice in a simulated environment that closely resembles the actual Kubernetes certification exam interface.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 4V6" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.51471 7.51472L8.92893 8.92894" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 16H6" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.51471 24.4853L8.92893 23.0711" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 26V28" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.4853 24.4853L23.0711 23.0711" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26 16H28" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.4853 7.51472L23.0711 8.92894" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 20C18.2091 20 20 18.2091 20 16C20 13.7909 18.2091 12 16 12C13.7909 12 12 13.7909 12 16C12 18.2091 13.7909 20 16 20Z" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>Multiple Certification Support</h4>
|
||||
<p>Practice for CKA, CKAD, and other Kubernetes certification exams with tailored scenarios for each exam type.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="12" stroke="#4f46e5" stroke-width="2.5"/>
|
||||
<path d="M16 10V16L20 20" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>Timed Practice</h4>
|
||||
<p>Prepare for the time pressure of the actual exams with our realistic timer functionality.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 16H26" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 8H26" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 24H26" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="10" cy="8" r="2" fill="#4f46e5"/>
|
||||
<circle cx="22" cy="16" r="2" fill="#4f46e5"/>
|
||||
<circle cx="14" cy="24" r="2" fill="#4f46e5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>Detailed Scoring & Solutions</h4>
|
||||
<p>Review your performance with comprehensive exam scores and access detailed solutions to enhance your learning.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 28C22.6274 28 28 22.6274 28 16C28 9.37258 22.6274 4 16 4C9.37258 4 4 9.37258 4 16C4 22.6274 9.37258 28 16 28Z" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 16L15 20L22 13" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>Realistic Kubernetes Cluster</h4>
|
||||
<p>Practice on actual Kubernetes infrastructure that mimics the exam environment with pre-configured namespaces and resources.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-container">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26 28V25C26 23.9391 25.5786 22.9217 24.8284 22.1716C24.0783 21.4214 23.0609 21 22 21H10C8.93913 21 7.92172 21.4214 7.17157 22.1716C6.42143 22.9217 6 23.9391 6 25V28" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 17C18.7614 17 21 14.7614 21 12C21 9.23858 18.7614 7 16 7C13.2386 7 11 9.23858 11 12C11 14.7614 13.2386 17 16 17Z" stroke="#4f46e5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4>User-Focused Experience</h4>
|
||||
<p>Enjoy a streamlined and accessible interface designed to help you focus on mastering Kubernetes concepts without distractions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Add loading overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-content">
|
||||
<h2>Preparing Your Lab Environment</h2>
|
||||
<div class="custom-progress-bar">
|
||||
<div class="custom-progress" id="progressBar"></div>
|
||||
</div>
|
||||
<div class="loading-message" id="loadingMessage">Initializing environment...</div>
|
||||
<div class="exam-info" id="examInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exam Selection Modal -->
|
||||
<div class="modal fade" id="examSelectionModal" tabindex="-1" aria-labelledby="examSelectionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="examSelectionModalLabel">Select Your Exam</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="examSelectionForm">
|
||||
<div class="mb-3">
|
||||
<label for="examCategory" class="form-label">Certification Type</label>
|
||||
<select class="form-select" id="examCategory" required>
|
||||
<option value="">Select certification type</option>
|
||||
<option value="CKAD" selected>CKAD - Certified Kubernetes Application Developer</option>
|
||||
<option value="CKA">CKA - Certified Kubernetes Administrator</option>
|
||||
<option value="CKS">CKS - Certified Kubernetes Security Specialist</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="examName" class="form-label">Exam</label>
|
||||
<select class="form-select" id="examName" required disabled>
|
||||
<option value="">Select an exam</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="alert alert-info" id="examDescription">
|
||||
Select an exam to see its description.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">CANCEL</button>
|
||||
<button type="button" class="btn btn-primary" id="startSelectedExam" disabled>START EXAM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="/js/index.js"></script>
|
||||
<!-- Buy Me a Coffee Widget -->
|
||||
<script data-name="BMC-Widget" data-cfasync="false" src="https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js" data-id="nishan.b" data-description="Support me on Buy me a coffee!" data-message="" data-color="#5F7FFF" data-position="Right" data-x_margin="18" data-y_margin="18"></script>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-center py-4 mt-4">
|
||||
<p>Made with <span style="color: #ff3366;">❤️</span> | <a href="https://ckx.nishann.com" target="_blank">ckx.nishann.com</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
117
app/public/js/answers.js
Normal file
117
app/public/js/answers.js
Normal file
@@ -0,0 +1,117 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const pageLoader = document.getElementById('pageLoader');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const errorText = document.getElementById('errorText');
|
||||
const retryButton = document.getElementById('retryButton');
|
||||
const answersContent = document.getElementById('answersContent');
|
||||
const examIdElement = document.getElementById('examId');
|
||||
const markdownContent = document.getElementById('markdownContent');
|
||||
const backToResultsBtn = document.getElementById('backToResultsBtn');
|
||||
|
||||
// Variables
|
||||
let currentExamId = null;
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
// Get exam ID from URL
|
||||
currentExamId = getExamIdFromUrl();
|
||||
|
||||
if (!currentExamId) {
|
||||
showError('No exam ID provided. Please return to the results page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update exam ID display
|
||||
examIdElement.textContent = `Exam ID: ${currentExamId}`;
|
||||
|
||||
// Fetch answers
|
||||
fetchAnswers(currentExamId);
|
||||
|
||||
// Setup event listeners
|
||||
backToResultsBtn.addEventListener('click', goBackToResults);
|
||||
retryButton.addEventListener('click', () => fetchAnswers(currentExamId));
|
||||
}
|
||||
|
||||
// Get exam ID from URL
|
||||
function getExamIdFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('id');
|
||||
}
|
||||
|
||||
// Go back to results page
|
||||
function goBackToResults() {
|
||||
if (currentExamId) {
|
||||
window.location.href = `/results.html?id=${currentExamId}`;
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
pageLoader.style.display = 'none';
|
||||
errorText.textContent = message;
|
||||
errorMessage.style.display = 'block';
|
||||
answersContent.style.display = 'none';
|
||||
}
|
||||
|
||||
// Fetch answers from API
|
||||
function fetchAnswers(examId) {
|
||||
// Show loader
|
||||
pageLoader.style.display = 'flex';
|
||||
errorMessage.style.display = 'none';
|
||||
answersContent.style.display = 'none';
|
||||
|
||||
// Fetch answers file
|
||||
fetch(`/facilitator/api/v1/exams/${examId}/answers`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.text(); // Get raw text (Markdown content)
|
||||
})
|
||||
.then(markdownText => {
|
||||
// Render markdown
|
||||
renderMarkdown(markdownText);
|
||||
|
||||
// Hide loader and show content
|
||||
pageLoader.style.display = 'none';
|
||||
answersContent.style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching answers:', error);
|
||||
showError(`Failed to load answers: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Render markdown content
|
||||
function renderMarkdown(markdownText) {
|
||||
// Configure marked options
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} else {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
},
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
// Convert markdown to HTML
|
||||
const htmlContent = marked.parse(markdownText);
|
||||
|
||||
// Set content
|
||||
markdownContent.innerHTML = htmlContent;
|
||||
|
||||
// Apply syntax highlighting to code blocks
|
||||
document.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the application
|
||||
init();
|
||||
});
|
||||
20
app/public/js/app.js
Normal file
20
app/public/js/app.js
Normal file
@@ -0,0 +1,20 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const vncFrame = document.getElementById('vnc-frame');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
||||
|
||||
connectBtn.addEventListener('click', function() {
|
||||
// Connect to the VNC server through the service
|
||||
vncFrame.src = `http://${window.location.hostname}:${window.location.port}/vnc-proxy/`;
|
||||
});
|
||||
|
||||
fullscreenBtn.addEventListener('click', function() {
|
||||
if (vncFrame.requestFullscreen) {
|
||||
vncFrame.requestFullscreen();
|
||||
} else if (vncFrame.webkitRequestFullscreen) {
|
||||
vncFrame.webkitRequestFullscreen();
|
||||
} else if (vncFrame.msRequestFullscreen) {
|
||||
vncFrame.msRequestFullscreen();
|
||||
}
|
||||
});
|
||||
});
|
||||
50
app/public/js/components/clipboard-service.js
Normal file
50
app/public/js/components/clipboard-service.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Clipboard Service
|
||||
* Handles clipboard-related functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy text to remote desktop clipboard via facilitator API
|
||||
* @param {string} content - Text content to copy
|
||||
* @private
|
||||
*/
|
||||
async function copyToRemoteClipboard(content) {
|
||||
try {
|
||||
// Fire and forget API call
|
||||
fetch('/facilitator/api/v1/remote-desktop/clipboard', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to remote clipboard:', error);
|
||||
// Don't throw error as this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup click-to-copy functionality for inline code elements
|
||||
* Uses event delegation to handle all inline-code elements
|
||||
*/
|
||||
function setupInlineCodeCopy() {
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target && event.target.matches('.inline-code')) {
|
||||
const codeText = event.target.textContent;
|
||||
|
||||
// Copy to remote desktop clipboard
|
||||
copyToRemoteClipboard(codeText);
|
||||
|
||||
// Copy to local clipboard
|
||||
navigator.clipboard.writeText(codeText).catch(err => {
|
||||
console.error('Could not copy text to clipboard:', err);
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
setupInlineCodeCopy
|
||||
};
|
||||
144
app/public/js/components/exam-api.js
Normal file
144
app/public/js/components/exam-api.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Exam API Service
|
||||
* Handles all API interactions for the exam functionality
|
||||
*/
|
||||
|
||||
// Function to get exam ID from URL
|
||||
function getExamId() {
|
||||
// Extract exam ID from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const examId = urlParams.get('id');
|
||||
|
||||
if (!examId) {
|
||||
console.error('No exam ID found in URL');
|
||||
alert('Error: No exam ID provided. Please return to the dashboard.');
|
||||
// redirect to dashboard
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
return examId;
|
||||
}
|
||||
|
||||
// Function to check exam status
|
||||
function checkExamStatus(examId) {
|
||||
return fetch(`/facilitator/api/v1/exams/${examId}/status`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
return data.status || null;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to fetch exam data
|
||||
function fetchExamData(examId) {
|
||||
const apiUrl = `/facilitator/api/v1/exams/${examId}/questions`;
|
||||
|
||||
return fetch(apiUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
return data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam questions:', error);
|
||||
throw error; // Re-throw to be handled by the calling function
|
||||
});
|
||||
}
|
||||
|
||||
// Function to fetch current exam information
|
||||
function fetchCurrentExamInfo() {
|
||||
return fetch('/facilitator/api/v1/exams/current')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to evaluate exam
|
||||
function evaluateExam(examId) {
|
||||
return fetch(`/facilitator/api/v1/exams/${examId}/evaluate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Function to terminate session
|
||||
function terminateSession(examId) {
|
||||
return fetch(`/facilitator/api/v1/exams/${examId}/terminate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Function to get VNC info
|
||||
function getVncInfo() {
|
||||
return fetch('/api/vnc-info')
|
||||
.then(response => response.json())
|
||||
.catch(error => {
|
||||
console.error('Error fetching VNC info:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to track exam events
|
||||
function trackExamEvent(examId, events) {
|
||||
return fetch(`/facilitator/api/v1/exams/${examId}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ events })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error tracking exam event:', error);
|
||||
// Don't throw error to avoid disrupting exam flow
|
||||
// But still log it for debugging
|
||||
});
|
||||
}
|
||||
|
||||
// Export the API functions
|
||||
export {
|
||||
getExamId,
|
||||
checkExamStatus,
|
||||
fetchExamData,
|
||||
fetchCurrentExamInfo,
|
||||
evaluateExam,
|
||||
terminateSession,
|
||||
getVncInfo,
|
||||
trackExamEvent
|
||||
};
|
||||
178
app/public/js/components/question-service.js
Normal file
178
app/public/js/components/question-service.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Question Service
|
||||
* Handles question display and navigation
|
||||
*/
|
||||
|
||||
// Process question content to improve formatting and highlighting
|
||||
function processQuestionContent(content) {
|
||||
// First, preserve existing HTML formatting
|
||||
let processedContent = content;
|
||||
|
||||
// Add highlighting to text in single quotes that isn't already styled
|
||||
processedContent = processedContent.replace(
|
||||
/`([^`]+)`/g,
|
||||
function(match, text) {
|
||||
// Skip if already inside an HTML tag or existing styled element
|
||||
if (match.match(/<.*>/) ||
|
||||
match.includes('class="code"') ||
|
||||
match.includes('class="highlight"')) {
|
||||
return match;
|
||||
}
|
||||
return `<span class="inline-code">${text}</span>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Style inline code with backticks if not already styled
|
||||
processedContent = processedContent.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-light px-1 rounded">$1</code>'
|
||||
);
|
||||
|
||||
// Style bold text
|
||||
processedContent = processedContent.replace(
|
||||
/\*\*([^*]+)\*\*/g,
|
||||
'<strong>$1</strong>'
|
||||
);
|
||||
|
||||
// Style italic text
|
||||
processedContent = processedContent.replace(
|
||||
/\*([^*]+)\*/g,
|
||||
'<em>$1</em>'
|
||||
);
|
||||
|
||||
// Convert literal newline characters to HTML line breaks
|
||||
processedContent = processedContent.replace(/\n/g, '<br>');
|
||||
|
||||
// Ensure paragraphs have proper spacing and line breaks
|
||||
processedContent = processedContent.replace(
|
||||
/<\/p><p>/g,
|
||||
'</p>\n<p>'
|
||||
);
|
||||
|
||||
// Add more spacing between list items
|
||||
processedContent = processedContent.replace(
|
||||
/<\/li><li>/g,
|
||||
'</li>\n<li>'
|
||||
);
|
||||
|
||||
return processedContent;
|
||||
}
|
||||
|
||||
// Generate question content HTML
|
||||
function generateQuestionContent(question) {
|
||||
try {
|
||||
// Get original data
|
||||
const originalData = question.originalData || {};
|
||||
const machineHostname = originalData.machineHostname || 'N/A';
|
||||
const namespace = originalData.namespace || 'N/A';
|
||||
const concepts = originalData.concepts || [];
|
||||
const conceptsString = concepts.join(', ');
|
||||
|
||||
// Format question content with improved styling
|
||||
const formattedQuestionContent = processQuestionContent(question.content);
|
||||
|
||||
// Create formatted content with minimal layout
|
||||
return `
|
||||
<div class="d-flex flex-column" style="height: 100%;">
|
||||
<div class="question-header">
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Solve this question on instance:</strong> <span class="inline-code">ssh ${machineHostname}</span></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Namespace:</strong> <span class="text-primary">${namespace}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Concepts:</strong> <span class="text-primary">${conceptsString}</span>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
</div>
|
||||
|
||||
<div class="question-body">
|
||||
${formattedQuestionContent}
|
||||
</div>
|
||||
|
||||
<div class="action-buttons-container mt-auto">
|
||||
<div class="d-flex justify-content-between py-2">
|
||||
<button class="btn ${question.flagged ? 'btn-warning' : 'btn-outline-warning'}" id="flagQuestionBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-flag${question.flagged ? '-fill' : ''} me-2" viewBox="0 0 16 16">
|
||||
<path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/>
|
||||
</svg>
|
||||
${question.flagged ? 'Flagged' : 'Flag for review'}
|
||||
</button>
|
||||
<button class="btn btn-success" id="nextQuestionBtn">
|
||||
Satisfied with answer
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-right ms-2" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error generating question content:', error);
|
||||
return '<div class="alert alert-danger">Error displaying question content. Please try refreshing the page.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Transform API response to question objects
|
||||
function transformQuestionsFromApi(data) {
|
||||
if (data.questions && Array.isArray(data.questions)) {
|
||||
// Transform the questions to match our expected format
|
||||
return data.questions.map(q => ({
|
||||
id: q.id,
|
||||
content: q.question || '', // Map 'question' field to 'content'
|
||||
title: `Question ${q.id}`, // Create a title from the ID
|
||||
originalData: q, // Keep original data for reference if needed
|
||||
flagged: false // Add flagged status property
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Update question dropdown
|
||||
function updateQuestionDropdown(questionsArray, dropdownMenu, currentId, onQuestionSelect) {
|
||||
// Clear existing dropdown items
|
||||
dropdownMenu.innerHTML = '';
|
||||
|
||||
// Add items for each question
|
||||
questionsArray.forEach((question) => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.className = 'dropdown-item';
|
||||
a.href = '#';
|
||||
a.dataset.question = question.id;
|
||||
a.textContent = `Question ${question.id}`;
|
||||
|
||||
// Add flag icon if question is flagged
|
||||
if (question.flagged) {
|
||||
const flagIcon = document.createElement('span');
|
||||
flagIcon.className = 'flag-icon ms-2';
|
||||
flagIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-flag-fill text-warning" viewBox="0 0 16 16"><path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/></svg>';
|
||||
a.appendChild(flagIcon);
|
||||
}
|
||||
|
||||
// Add click event
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const clickedQuestionId = this.dataset.question;
|
||||
if (onQuestionSelect) {
|
||||
onQuestionSelect(clickedQuestionId);
|
||||
}
|
||||
});
|
||||
|
||||
li.appendChild(a);
|
||||
dropdownMenu.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
processQuestionContent,
|
||||
generateQuestionContent,
|
||||
transformQuestionsFromApi,
|
||||
updateQuestionDropdown
|
||||
};
|
||||
66
app/public/js/components/remote-desktop-service.js
Normal file
66
app/public/js/components/remote-desktop-service.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Remote Desktop Service
|
||||
* Handles remote desktop connection and management
|
||||
*/
|
||||
import { getVncInfo } from './exam-api.js';
|
||||
|
||||
// Connect to VNC
|
||||
function connectToRemoteDesktop(vncFrame, statusCallback) {
|
||||
if (statusCallback) {
|
||||
statusCallback('Connecting to Remote Desktop...', 'info');
|
||||
}
|
||||
|
||||
// Get VNC server info from API
|
||||
return getVncInfo()
|
||||
.then(data => {
|
||||
console.log('Remote Desktop info:', data);
|
||||
|
||||
// Create the VNC URL with proper parameters
|
||||
const vncUrl = `/vnc-proxy/?autoconnect=true&resize=scale&show_dot=true&reconnect=true&password=${data.defaultPassword}`;
|
||||
|
||||
// Set the iframe source to the VNC URL
|
||||
vncFrame.src = vncUrl;
|
||||
if (statusCallback) {
|
||||
statusCallback('Connected to Session', 'success');
|
||||
}
|
||||
return vncUrl;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error connecting to Remote Desktop:', error);
|
||||
if (statusCallback) {
|
||||
statusCallback('Failed to connect to Remote Desktop. Retrying...', 'error');
|
||||
}
|
||||
// Return a promise that will retry
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(connectToRemoteDesktop(vncFrame, statusCallback));
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Setup Remote Desktop frame event handlers
|
||||
function setupRemoteDesktopFrameHandlers(vncFrame, statusCallback) {
|
||||
vncFrame.addEventListener('load', function() {
|
||||
if (vncFrame.src !== 'about:blank') {
|
||||
console.log('Remote Desktop frame loaded successfully');
|
||||
if (statusCallback) {
|
||||
statusCallback('Connected to Session', 'success');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
vncFrame.addEventListener('error', function(e) {
|
||||
console.error('Error loading Remote Desktop frame:', e);
|
||||
if (statusCallback) {
|
||||
statusCallback('Error connecting to Remote Desktop. Retrying...', 'error');
|
||||
}
|
||||
// Try to reconnect after a delay
|
||||
setTimeout(() => connectToRemoteDesktop(vncFrame, statusCallback), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
connectToRemoteDesktop,
|
||||
setupRemoteDesktopFrameHandlers
|
||||
};
|
||||
261
app/public/js/components/terminal-service.js
Normal file
261
app/public/js/components/terminal-service.js
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Terminal Service
|
||||
* Handles terminal initialization and management
|
||||
*/
|
||||
|
||||
let terminal;
|
||||
let socket;
|
||||
let fitAddon;
|
||||
let isTerminalInitialized = false;
|
||||
let terminalCallbacks = {};
|
||||
|
||||
// Set up terminal callbacks
|
||||
function setCallbacks(callbacks) {
|
||||
terminalCallbacks = callbacks || {};
|
||||
}
|
||||
|
||||
// Function to initialize the terminal
|
||||
function initTerminal(containerElement, isActive = false) {
|
||||
// If already initialized, just resize
|
||||
if (isTerminalInitialized) {
|
||||
console.log('Terminal already initialized, just resizing');
|
||||
resizeTerminal(containerElement);
|
||||
return { terminal, fitAddon };
|
||||
}
|
||||
|
||||
console.log('Initializing terminal for the first time');
|
||||
|
||||
// Create terminal container if it doesn't exist
|
||||
let terminalContainer = document.getElementById('terminal');
|
||||
if (!terminalContainer) {
|
||||
terminalContainer = document.createElement('div');
|
||||
terminalContainer.id = 'terminal';
|
||||
terminalContainer.className = 'terminal';
|
||||
containerElement.appendChild(terminalContainer);
|
||||
}
|
||||
|
||||
// Set initial container size
|
||||
terminalContainer.style.width = '100%';
|
||||
terminalContainer.style.height = `${containerElement.clientHeight}px`;
|
||||
terminalContainer.style.maxWidth = '100%';
|
||||
terminalContainer.style.boxSizing = 'border-box';
|
||||
|
||||
// Create a new terminal
|
||||
terminal = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
|
||||
fontSize: 18,
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1E1E1E',
|
||||
foreground: '#F8F8F8',
|
||||
cursor: '#A0A0A0',
|
||||
selectionBackground: '#363B4E'
|
||||
},
|
||||
cursorBlink: true,
|
||||
scrollback: 10000,
|
||||
allowTransparency: true,
|
||||
disableStdin: false
|
||||
});
|
||||
|
||||
// Create a fit addon
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
// Open the terminal in the container
|
||||
terminal.open(terminalContainer);
|
||||
fitAddon.fit();
|
||||
|
||||
// Connect to Socket.io server
|
||||
connectToSocketIO();
|
||||
|
||||
// Add window resize listener to keep terminal properly sized
|
||||
window.addEventListener('resize', () => {
|
||||
if (isActive) {
|
||||
resizeTerminal(containerElement);
|
||||
}
|
||||
});
|
||||
|
||||
isTerminalInitialized = true;
|
||||
return { terminal, fitAddon };
|
||||
}
|
||||
|
||||
// Resize terminal to fit container
|
||||
function resizeTerminal(containerElement) {
|
||||
if (!terminal || !isTerminalInitialized) return;
|
||||
|
||||
setTimeout(() => {
|
||||
// Recalculate terminal container size
|
||||
const terminalContainer = document.getElementById('terminal');
|
||||
const containerHeight = containerElement.clientHeight;
|
||||
const containerWidth = containerElement.clientWidth;
|
||||
|
||||
if (terminalContainer && containerHeight && containerWidth) {
|
||||
terminalContainer.style.height = `${containerHeight}px`;
|
||||
terminalContainer.style.width = `${containerWidth}px`;
|
||||
|
||||
// Ensure the terminal div takes full available space
|
||||
terminalContainer.style.maxWidth = '100%';
|
||||
terminalContainer.style.boxSizing = 'border-box';
|
||||
}
|
||||
|
||||
// Use the existing fitAddon instead of creating a new one
|
||||
if (fitAddon) {
|
||||
fitAddon.fit();
|
||||
console.log(`Terminal resized to ${terminal.cols} columns by ${terminal.rows} rows`);
|
||||
|
||||
// Update server with new dimensions
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('resize', {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('fitAddon not available for resizing');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Connect to SSH and handle reconnection
|
||||
function connectToSSH() {
|
||||
if (terminalCallbacks.showConnectionStatus) {
|
||||
terminalCallbacks.showConnectionStatus('Connecting to terminal...', 'info');
|
||||
}
|
||||
|
||||
// Clear terminal if it has content
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
// Connect to Socket.io server immediately without animation
|
||||
connectToSocketIO();
|
||||
}
|
||||
|
||||
// Connect to Socket.io
|
||||
function connectToSocketIO() {
|
||||
// Don't reconnect if socket already exists and is connected
|
||||
if (socket && socket.connected) {
|
||||
console.log('Socket already connected, skipping reconnection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect existing socket if it exists
|
||||
if (socket) {
|
||||
console.log('Disconnecting existing socket before creating a new one');
|
||||
socket.off('data');
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
socket.off('error');
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
// Connect to Socket.io server
|
||||
socket = io('/ssh', {
|
||||
forceNew: true,
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 10000
|
||||
});
|
||||
console.log('Creating new socket connection to SSH server');
|
||||
|
||||
// Handle connection events
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to SSH server');
|
||||
if (terminalCallbacks.showConnectionStatus) {
|
||||
terminalCallbacks.showConnectionStatus('Connected to terminal', 'success');
|
||||
}
|
||||
|
||||
// Send terminal size to server
|
||||
const dimensions = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows
|
||||
};
|
||||
socket.emit('resize', dimensions);
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from SSH server');
|
||||
if (terminalCallbacks.showConnectionStatus) {
|
||||
terminalCallbacks.showConnectionStatus('Disconnected from terminal. Reconnecting...', 'error');
|
||||
}
|
||||
|
||||
// Show disconnected message in terminal with styling
|
||||
if (terminal) {
|
||||
terminal.writeln('\r\n\r\n\x1b[1;31m[CONNECTION LOST]\x1b[0m Terminal disconnected.');
|
||||
terminal.writeln('\x1b[0;33mAttempting to reconnect...\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Try to reconnect after a delay
|
||||
setTimeout(() => {
|
||||
if (socket && !socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Handle connection error
|
||||
socket.on('error', (err) => {
|
||||
console.error('Socket connection error:', err);
|
||||
if (terminal) {
|
||||
terminal.writeln(`\r\n\x1b[1;31m[ERROR]\x1b[0m ${err.message}\r\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SSH data with processing for ANSI codes
|
||||
socket.on('data', (data) => {
|
||||
if (terminal) {
|
||||
terminal.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear any existing listeners to prevent duplication
|
||||
if (terminal) {
|
||||
// Get all registered event listeners
|
||||
const existingListeners = terminal._core._events;
|
||||
|
||||
// If we have existing data listeners, clear them
|
||||
if (existingListeners && existingListeners.data) {
|
||||
// Remove previous data listeners
|
||||
terminal._core.off('data');
|
||||
console.log('Removed existing terminal data listeners');
|
||||
}
|
||||
|
||||
// Add our data handler
|
||||
terminal.onData((data) => {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('data', data);
|
||||
} else {
|
||||
// Visual feedback when trying to type while disconnected
|
||||
terminal.write('\r\n\x1b[1;31m[DISCONNECTED]\x1b[0m Cannot send data. Reconnecting...\r\n');
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
console.log('Added new terminal data listener');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if terminal is initialized
|
||||
function isInitialized() {
|
||||
return isTerminalInitialized;
|
||||
}
|
||||
|
||||
// Get terminal instance
|
||||
function getTerminal() {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// Get socket instance
|
||||
function getSocket() {
|
||||
return socket;
|
||||
}
|
||||
|
||||
// Export functions
|
||||
export {
|
||||
initTerminal,
|
||||
resizeTerminal,
|
||||
connectToSSH,
|
||||
isInitialized,
|
||||
getTerminal,
|
||||
getSocket,
|
||||
setCallbacks
|
||||
};
|
||||
218
app/public/js/components/timer-service.js
Normal file
218
app/public/js/components/timer-service.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Timer Service
|
||||
* Handles timer functionality for the exam
|
||||
*/
|
||||
|
||||
let timerDuration = 120; // Default timer duration in minutes
|
||||
let timerInterval = null;
|
||||
let timerElement = null;
|
||||
let onTimerEndCallback = null;
|
||||
let isTimerRunning = false;
|
||||
const timeThresholds = [30, 10, 0]; // Time thresholds in minutes
|
||||
let timeThresholdCallbacks = {}; // Callbacks for time thresholds
|
||||
let processedThresholds = new Set(); // Track which thresholds have been processed
|
||||
|
||||
/**
|
||||
* Set timer duration
|
||||
* @param {number} duration - Duration in minutes
|
||||
*/
|
||||
function setTimerDuration(duration) {
|
||||
timerDuration = duration;
|
||||
// Reset processed thresholds when timer duration changes
|
||||
processedThresholds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callbacks for specific time thresholds
|
||||
* @param {Object} callbacks - Object with threshold minutes as keys and callback functions as values
|
||||
*/
|
||||
function registerTimeThresholdCallbacks(callbacks) {
|
||||
timeThresholdCallbacks = { ...timeThresholdCallbacks, ...callbacks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time
|
||||
* @returns {number} Remaining time in minutes
|
||||
*/
|
||||
function getRemainingTime() {
|
||||
return timerDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate remaining exam time based on exam info
|
||||
* @param {Object} examInfo - The exam information object
|
||||
* @returns {number} Remaining time in minutes
|
||||
*/
|
||||
function calculateRemainingTime(examInfo) {
|
||||
// If exam hasn't started yet or no duration set, return default
|
||||
if (!examInfo.info?.examDurationInMinutes) {
|
||||
return getRemainingTime();
|
||||
}
|
||||
|
||||
// Check if exam has ended
|
||||
if (examInfo.info?.events?.examEndTime) {
|
||||
return 0; // No time remaining if exam is finished
|
||||
}
|
||||
|
||||
// If exam has started, calculate remaining time
|
||||
if (examInfo.info?.events?.examStartTime) {
|
||||
// Make sure we have a proper number value for startTime
|
||||
let startTime = examInfo.info.events.examStartTime;
|
||||
|
||||
// Parse the timestamp to ensure it's a number (could be stored as string)
|
||||
if (typeof startTime === 'string') {
|
||||
startTime = parseInt(startTime, 10);
|
||||
}
|
||||
|
||||
// Debug log to identify the issue
|
||||
console.log('Start time (epoch):', startTime);
|
||||
console.log('Current time (epoch):', Date.now());
|
||||
console.log('Exam duration (minutes):', examInfo.info.examDurationInMinutes);
|
||||
|
||||
const durationMs = examInfo.info.examDurationInMinutes * 60 * 1000;
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
const remainingMs = Math.max(0, durationMs - elapsedMs);
|
||||
|
||||
// Debug log for troubleshooting
|
||||
console.log('Duration (ms):', durationMs);
|
||||
console.log('Elapsed (ms):', elapsedMs);
|
||||
console.log('Remaining (ms):', remainingMs);
|
||||
console.log('Remaining (minutes):', Math.ceil(remainingMs / (60 * 1000)));
|
||||
|
||||
// Convert ms to minutes
|
||||
return Math.ceil(remainingMs / (60 * 1000));
|
||||
}
|
||||
|
||||
// Default: return full duration if exam hasn't started
|
||||
return examInfo.info.examDurationInMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the timer
|
||||
* @param {HTMLElement} element - DOM element to display the timer
|
||||
* @param {number} minutes - Timer duration in minutes
|
||||
* @param {Object} options - Optional configuration
|
||||
* @param {Function} options.onTimerEnd - Callback function when timer ends
|
||||
*/
|
||||
function initTimer(element, minutes, options = {}) {
|
||||
timerElement = element;
|
||||
timerDuration = minutes;
|
||||
onTimerEndCallback = options.onTimerEnd;
|
||||
|
||||
// Reset processed thresholds when initializing new timer
|
||||
processedThresholds.clear();
|
||||
|
||||
updateTimerDisplay(minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a time threshold has been reached and trigger callback
|
||||
* @param {number} remainingMinutes - Remaining time in minutes
|
||||
* @param {number} remainingSeconds - Remaining time in seconds
|
||||
*/
|
||||
function checkTimeThresholds(remainingMinutes, remainingSeconds) {
|
||||
// Check each threshold
|
||||
timeThresholds.forEach(threshold => {
|
||||
// If we just crossed this threshold and haven't processed it yet
|
||||
if (remainingMinutes === threshold &&
|
||||
remainingSeconds === 0 &&
|
||||
!processedThresholds.has(threshold)) {
|
||||
|
||||
// Mark as processed
|
||||
processedThresholds.add(threshold);
|
||||
|
||||
// Call the registered callback if exists
|
||||
if (timeThresholdCallbacks[threshold]) {
|
||||
timeThresholdCallbacks[threshold](threshold);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the timer
|
||||
*/
|
||||
function startTimer() {
|
||||
if (isTimerRunning) return;
|
||||
isTimerRunning = true;
|
||||
|
||||
let remainingSeconds = timerDuration * 60;
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
remainingSeconds--;
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
stopTimer();
|
||||
updateTimerDisplay(0);
|
||||
|
||||
if (onTimerEndCallback) {
|
||||
onTimerEndCallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
|
||||
// Check if we've hit any time thresholds
|
||||
checkTimeThresholds(minutes, seconds);
|
||||
|
||||
updateTimerDisplay(minutes, seconds);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the timer
|
||||
*/
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
isTimerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timer
|
||||
*/
|
||||
function resetTimer() {
|
||||
stopTimer();
|
||||
timerDuration = 120; // Reset to default
|
||||
processedThresholds.clear();
|
||||
if (timerElement) {
|
||||
updateTimerDisplay(timerDuration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the timer display
|
||||
* @param {number} minutes - Minutes remaining
|
||||
* @param {number} seconds - Seconds remaining
|
||||
*/
|
||||
function updateTimerDisplay(minutes, seconds = 0) {
|
||||
if (!timerElement) return;
|
||||
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const formattedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
timerElement.textContent = `${formattedMinutes}:${formattedSeconds}`;
|
||||
|
||||
// Add visual warning when less than 5 minutes remaining
|
||||
if (minutes < 5) {
|
||||
timerElement.classList.add('timer-warning');
|
||||
} else {
|
||||
timerElement.classList.remove('timer-warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Export the timer service
|
||||
export {
|
||||
setTimerDuration,
|
||||
getRemainingTime,
|
||||
calculateRemainingTime,
|
||||
initTimer,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
registerTimeThresholdCallbacks
|
||||
};
|
||||
207
app/public/js/components/ui-utils.js
Normal file
207
app/public/js/components/ui-utils.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* UI Utilities
|
||||
* Handles common UI functions and interactions
|
||||
*/
|
||||
|
||||
// Format time as mm:ss
|
||||
function formatTime(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Show connection status notification
|
||||
function showConnectionStatus(element, message, type) {
|
||||
if (!element) return;
|
||||
|
||||
element.textContent = message;
|
||||
element.className = 'connection-status show';
|
||||
|
||||
if (type === 'success') {
|
||||
element.style.backgroundColor = 'rgba(25, 135, 84, 0.7)';
|
||||
} else if (type === 'error') {
|
||||
element.style.backgroundColor = 'rgba(220, 53, 69, 0.7)';
|
||||
} else {
|
||||
element.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||
}
|
||||
|
||||
// Hide the status after a few seconds for success messages
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
element.className = 'connection-status';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to request fullscreen mode
|
||||
function requestFullscreen(element) {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.mozRequestFullScreen) { // Firefox
|
||||
element.mozRequestFullScreen();
|
||||
} else if (element.webkitRequestFullscreen) { // Chrome, Safari, Opera
|
||||
element.webkitRequestFullscreen();
|
||||
} else if (element.msRequestFullscreen) { // IE/Edge
|
||||
element.msRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to exit fullscreen mode
|
||||
function exitFullscreen() {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) { // Firefox
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.webkitExitFullscreen) { // Chrome, Safari, Opera
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.msExitFullscreen) { // IE/Edge
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to toggle fullscreen mode
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement &&
|
||||
!document.mozFullScreenElement &&
|
||||
!document.webkitFullscreenElement &&
|
||||
!document.msFullscreenElement) {
|
||||
requestFullscreen(document.documentElement);
|
||||
} else {
|
||||
exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Show modal for completed exams
|
||||
function showExamCompletedModal(examId, status) {
|
||||
// Create a modal to show exam completion and option to connect to session
|
||||
const completionModalHTML = `
|
||||
<div class="modal fade" id="examCompletedModal" tabindex="-1" aria-labelledby="examCompletedModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="examCompletedModalLabel">
|
||||
${status === 'EVALUATED' ? 'Exam Complete' : 'Exam Being Evaluated'}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${status === 'EVALUATED' ?
|
||||
'<p>This exam has been completed and your results are ready to view.</p>' :
|
||||
'<p>This exam has been completed and is currently being evaluated.</p><p>You can view the results once evaluation is complete.</p>'
|
||||
}
|
||||
<p>You can still connect to your exam environment to review your work.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" id="connectToSessionBtn">Connect to Session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Check if modal already exists, remove it if it does
|
||||
const existingModal = document.getElementById('examCompletedModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body
|
||||
document.body.insertAdjacentHTML('beforeend', completionModalHTML);
|
||||
|
||||
// Show modal
|
||||
const completionModal = new bootstrap.Modal(document.getElementById('examCompletedModal'));
|
||||
completionModal.show();
|
||||
|
||||
// Handle connect to session button click
|
||||
document.getElementById('connectToSessionBtn').addEventListener('click', () => {
|
||||
// Close the modal
|
||||
completionModal.hide();
|
||||
|
||||
// Remove the modal from DOM after hiding
|
||||
setTimeout(() => {
|
||||
const modalElement = document.getElementById('examCompletedModal');
|
||||
if (modalElement) {
|
||||
const modalBackdrop = document.querySelector('.modal-backdrop');
|
||||
if (modalBackdrop) {
|
||||
modalBackdrop.remove();
|
||||
}
|
||||
modalElement.remove();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Dispatch event to notify exam.js to connect to the session
|
||||
const examCompleteEvent = new CustomEvent('examCompletedSession', {
|
||||
detail: { examId }
|
||||
});
|
||||
|
||||
document.dispatchEvent(examCompleteEvent);
|
||||
});
|
||||
}
|
||||
|
||||
// Update UI based on fullscreen state
|
||||
function updateFullscreenUI(fullscreenBtn) {
|
||||
if (fullscreenBtn) {
|
||||
if (document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement) {
|
||||
fullscreenBtn.textContent = 'Exit Fullscreen';
|
||||
} else {
|
||||
fullscreenBtn.textContent = 'Enter Fullscreen';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show a toast notification
|
||||
function showToast(message, options = {}) {
|
||||
const toastContainer = document.getElementById('toastContainer');
|
||||
if (!toastContainer) return;
|
||||
|
||||
// Default options
|
||||
const defaults = {
|
||||
bgColor: 'bg-primary',
|
||||
textColor: 'text-white',
|
||||
autohide: true,
|
||||
delay: 5000
|
||||
};
|
||||
|
||||
// Merge with custom options
|
||||
const settings = { ...defaults, ...options };
|
||||
|
||||
// Create a unique ID for this toast
|
||||
const toastId = 'toast-' + Date.now();
|
||||
|
||||
// Create toast HTML
|
||||
const toastHTML = `
|
||||
<div class="toast ${settings.bgColor} ${settings.textColor}" id="${toastId}" role="alert" aria-live="assertive" aria-atomic="true" ${settings.autohide ? 'data-bs-autohide="true"' : 'data-bs-autohide="false"'} data-bs-delay="${settings.delay}">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">Exam Notification</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
|
||||
|
||||
// Initialize and show the toast
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const toast = new bootstrap.Toast(toastElement);
|
||||
toast.show();
|
||||
|
||||
// Return the toast instance for later reference
|
||||
return toast;
|
||||
}
|
||||
|
||||
export {
|
||||
formatTime,
|
||||
showConnectionStatus,
|
||||
requestFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
showExamCompletedModal,
|
||||
updateFullscreenUI,
|
||||
showToast
|
||||
};
|
||||
80
app/public/js/components/wake-lock-service.js
Normal file
80
app/public/js/components/wake-lock-service.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Wake Lock Service
|
||||
* Prevents device from sleeping during exam
|
||||
*/
|
||||
|
||||
let wakeLock = null;
|
||||
|
||||
/**
|
||||
* Acquire screen wake lock to prevent device from sleeping
|
||||
* @returns {Promise<boolean>} True if wake lock acquired successfully
|
||||
*/
|
||||
async function acquireWakeLock() {
|
||||
// Check if Wake Lock API is supported
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
// Attempt to acquire wake lock
|
||||
wakeLock = await navigator.wakeLock.request('screen');
|
||||
|
||||
console.log('Wake Lock activated');
|
||||
|
||||
// Add release event listener for page visibility change
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to acquire Wake Lock:', err);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.warn('Wake Lock API not supported in this browser');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visibility change to reacquire wake lock when page becomes visible again
|
||||
*/
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Reacquire wake lock when tab becomes visible again
|
||||
acquireWakeLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release wake lock manually
|
||||
*/
|
||||
async function releaseWakeLock() {
|
||||
if (wakeLock) {
|
||||
try {
|
||||
await wakeLock.release();
|
||||
wakeLock = null;
|
||||
console.log('Wake Lock released');
|
||||
|
||||
// Remove event listener
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to release Wake Lock:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if wake lock is currently active
|
||||
* @returns {boolean} True if wake lock is active
|
||||
*/
|
||||
function isWakeLockActive() {
|
||||
return wakeLock !== null;
|
||||
}
|
||||
|
||||
// Export the wake lock functions
|
||||
export {
|
||||
acquireWakeLock,
|
||||
releaseWakeLock,
|
||||
isWakeLockActive
|
||||
};
|
||||
903
app/public/js/exam.js
Normal file
903
app/public/js/exam.js
Normal file
@@ -0,0 +1,903 @@
|
||||
/**
|
||||
* Exam Application
|
||||
* Main entry point for the exam functionality
|
||||
*/
|
||||
|
||||
// Import services and utilities
|
||||
import * as ExamApi from './components/exam-api.js';
|
||||
import * as TerminalService from './components/terminal-service.js';
|
||||
import * as RemoteDesktopService from './components/remote-desktop-service.js';
|
||||
import * as QuestionService from './components/question-service.js';
|
||||
import * as TimerService from './components/timer-service.js';
|
||||
import * as UiUtils from './components/ui-utils.js';
|
||||
import * as WakeLockService from './components/wake-lock-service.js';
|
||||
import * as ClipboardService from './components/clipboard-service.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
const pageLoader = document.getElementById('pageLoader');
|
||||
const endExamBtnDropdown = document.getElementById('endExamBtnDropdown');
|
||||
const confirmEndBtn = document.getElementById('confirmEndBtn');
|
||||
const terminateSessionBtn = document.getElementById('terminateSessionBtn');
|
||||
const confirmTerminateBtn = document.getElementById('confirmTerminateBtn');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
const questionDropdown = document.getElementById('questionDropdown');
|
||||
const questionDropdownMenu = document.getElementById('questionDropdownMenu');
|
||||
const questionContent = document.getElementById('questionContent');
|
||||
const examTimer = document.getElementById('examTimer');
|
||||
const vncFrame = document.getElementById('vnc-frame');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const startExamBtn = document.getElementById('startExamBtn');
|
||||
const toggleViewBtn = document.getElementById('toggleViewBtn');
|
||||
const sshTerminalContainer = document.getElementById('sshTerminalContainer');
|
||||
const sshConnectionStatus = document.getElementById('sshConnectionStatus');
|
||||
const viewResultsBtn = document.getElementById('viewResultsBtn');
|
||||
|
||||
// Modals
|
||||
const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
|
||||
const terminateModal = new bootstrap.Modal(document.getElementById('terminateModal'));
|
||||
const startExamModal = new bootstrap.Modal(document.getElementById('startExamModal'));
|
||||
const examEndModal = new bootstrap.Modal(document.getElementById('examEndModal'));
|
||||
|
||||
// State variables
|
||||
let examInfo = {}; // Store exam information
|
||||
let currentQuestionId = 1;
|
||||
let questions = [];
|
||||
let isTerminalActive = false;
|
||||
let isCompletedExamMode = false;
|
||||
|
||||
// Add event listener for page unload to clean up resources
|
||||
window.addEventListener('beforeunload', cleanupResources);
|
||||
|
||||
// Initialize by fetching questions
|
||||
fetchExamQuestions();
|
||||
|
||||
// Listen for examCompletedSession event and handle connecting to a finished exam session
|
||||
document.addEventListener('examCompletedSession', function(event) {
|
||||
const { examId } = event.detail;
|
||||
console.log('Connecting to completed exam session for exam:', examId);
|
||||
|
||||
// Get DOM elements
|
||||
const pageLoader = document.getElementById('pageLoader');
|
||||
const vncFrame = document.getElementById('vnc-frame');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const examTimer = document.getElementById('examTimer');
|
||||
|
||||
// Set completed exam mode
|
||||
isCompletedExamMode = true;
|
||||
|
||||
// Show loader
|
||||
pageLoader.style.display = 'flex';
|
||||
|
||||
// Create a promises array for parallel fetching
|
||||
const promises = [
|
||||
// Fetch exam info
|
||||
ExamApi.fetchCurrentExamInfo(),
|
||||
// Fetch exam questions
|
||||
ExamApi.fetchExamData(examId)
|
||||
];
|
||||
|
||||
// Execute all promises in parallel
|
||||
Promise.all(promises)
|
||||
.then(([examInfoData, questionsData]) => {
|
||||
// Store exam info for later use
|
||||
examInfo = examInfoData;
|
||||
|
||||
// Hide timer for completed exam
|
||||
examTimer.style.display = 'none';
|
||||
|
||||
// Add Review Mode badge next to title
|
||||
const headerTitle = document.querySelector('.header-title');
|
||||
if (headerTitle) {
|
||||
// Create the badge
|
||||
const reviewBadge = document.createElement('span');
|
||||
reviewBadge.className = 'review-mode-badge';
|
||||
reviewBadge.textContent = 'Review Mode';
|
||||
|
||||
// Add badge next to title
|
||||
headerTitle.appendChild(reviewBadge);
|
||||
}
|
||||
|
||||
// Hide end exam button if it exists
|
||||
const endExamItem = document.querySelector('.dropdown-item[href="#"][id="endExamBtnDropdown"]');
|
||||
if (endExamItem) {
|
||||
endExamItem.style.display = 'none';
|
||||
}
|
||||
|
||||
if (questionsData) {
|
||||
// Transform the questions
|
||||
questions = QuestionService.transformQuestionsFromApi(questionsData);
|
||||
|
||||
// Initialize the exam UI with questions
|
||||
initExamUI();
|
||||
|
||||
console.log('Loaded', questions.length, 'questions for completed exam');
|
||||
|
||||
// Add "View Results" button to header
|
||||
const headerControls = document.querySelector('.header-controls');
|
||||
if (headerControls) {
|
||||
const viewResultsBtn = document.createElement('button');
|
||||
viewResultsBtn.className = 'btn-custom';
|
||||
viewResultsBtn.textContent = 'View Results';
|
||||
viewResultsBtn.addEventListener('click', () => {
|
||||
window.location.href = `/results?id=${examId}`;
|
||||
});
|
||||
|
||||
// Add button to beginning of controls
|
||||
headerControls.prepend(viewResultsBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to Remote Desktop
|
||||
RemoteDesktopService.connectToRemoteDesktop(vncFrame, showVncConnectionStatus);
|
||||
|
||||
// Hide loader after a short delay
|
||||
setTimeout(() => {
|
||||
pageLoader.style.display = 'none';
|
||||
}, 1500);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam environment:', error);
|
||||
alert('Failed to connect to exam environment. Please try again.');
|
||||
pageLoader.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Function declarations
|
||||
|
||||
// Function to show VNC connection status
|
||||
function showVncConnectionStatus(message, type) {
|
||||
UiUtils.showConnectionStatus(connectionStatus, message, type);
|
||||
}
|
||||
|
||||
// Function to show SSH connection status
|
||||
function showSshConnectionStatus(message, type) {
|
||||
UiUtils.showConnectionStatus(sshConnectionStatus, message, type);
|
||||
}
|
||||
|
||||
// Setup callbacks for terminal service
|
||||
TerminalService.setCallbacks({
|
||||
showConnectionStatus: showSshConnectionStatus
|
||||
});
|
||||
|
||||
// Load exam environment for completed exams
|
||||
function loadExamEnvironment(examId) {
|
||||
// Show loader
|
||||
pageLoader.style.display = 'flex';
|
||||
|
||||
// Fetch exam info for connection details only
|
||||
ExamApi.fetchCurrentExamInfo()
|
||||
.then(data => {
|
||||
examInfo = data;
|
||||
|
||||
// Connect to environment
|
||||
connectToExamSession();
|
||||
|
||||
// Hide loader after a short delay
|
||||
setTimeout(() => {
|
||||
pageLoader.style.display = 'none';
|
||||
}, 1500);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam environment:', error);
|
||||
alert('Failed to connect to exam environment. Please try again.');
|
||||
pageLoader.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Connect to exam session for review
|
||||
function connectToExamSession() {
|
||||
console.log('Connecting to exam session in completed exam mode');
|
||||
|
||||
// Connect to Remote Desktop
|
||||
RemoteDesktopService.connectToRemoteDesktop(vncFrame, showVncConnectionStatus);
|
||||
|
||||
// Setup Remote Desktop frame handlers
|
||||
RemoteDesktopService.setupRemoteDesktopFrameHandlers(vncFrame, showVncConnectionStatus);
|
||||
|
||||
// Enter fullscreen mode for better visibility
|
||||
UiUtils.requestFullscreen(document.documentElement);
|
||||
}
|
||||
|
||||
// Fetch questions from API
|
||||
function fetchExamQuestions() {
|
||||
// Show loader while fetching questions
|
||||
pageLoader.style.display = 'flex';
|
||||
|
||||
const examId = ExamApi.getExamId();
|
||||
if (!examId) return;
|
||||
|
||||
// First check exam status to handle completed exams
|
||||
ExamApi.checkExamStatus(examId)
|
||||
.then(status => {
|
||||
if (status === 'EVALUATED' || status === 'EVALUATING') {
|
||||
// Show option to view results for completed exams
|
||||
UiUtils.showExamCompletedModal(examId, status);
|
||||
pageLoader.style.display = 'none';
|
||||
return null; // Skip loading questions
|
||||
}
|
||||
|
||||
// First fetch current exam info
|
||||
return ExamApi.fetchCurrentExamInfo()
|
||||
.then(data => {
|
||||
examInfo = data;
|
||||
|
||||
// Set timer based on examDurationInMinutes and examStartTime if available
|
||||
if (data.info) {
|
||||
if (data.info.events?.examStartTime && data.info.examDurationInMinutes) {
|
||||
// Calculate remaining time for in-progress exams
|
||||
const remainingMinutes = TimerService.calculateRemainingTime(data);
|
||||
console.log(`Setting timer to ${remainingMinutes} minutes (based on start time)`);
|
||||
TimerService.setTimerDuration(remainingMinutes);
|
||||
} else if (data.info.examDurationInMinutes) {
|
||||
// For new exams, use the full duration
|
||||
console.log(`Setting timer to ${data.info.examDurationInMinutes} minutes`);
|
||||
TimerService.setTimerDuration(data.info.examDurationInMinutes);
|
||||
} else {
|
||||
console.warn('examDurationInMinutes not found in API response, using default duration');
|
||||
}
|
||||
}
|
||||
|
||||
return ExamApi.fetchExamData(examId);
|
||||
})
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
|
||||
// Store exam info for later use
|
||||
examInfo = data.info || examInfo;
|
||||
|
||||
// Transform the questions
|
||||
questions = QuestionService.transformQuestionsFromApi(data);
|
||||
|
||||
// Initialize the exam UI after loading questions
|
||||
initExamUI();
|
||||
|
||||
// Show the start exam modal
|
||||
showStartExamModal();
|
||||
|
||||
// Hide loader
|
||||
pageLoader.style.display = 'none';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam:', error);
|
||||
// Show an error message to the user
|
||||
alert('Failed to load exam data. Please refresh the page or contact support.');
|
||||
pageLoader.style.display = 'none';
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam:', error);
|
||||
// Show an error message to the user
|
||||
alert('Failed to load exam data. Please refresh the page or contact support.');
|
||||
pageLoader.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Show start exam modal
|
||||
function showStartExamModal() {
|
||||
// Check if the exam has already been started or completed
|
||||
if (examInfo.info?.events?.examStartTime) {
|
||||
setupContinueExamButton();
|
||||
} else {
|
||||
setupNewExamButton();
|
||||
}
|
||||
|
||||
// Always show the modal regardless of exam state
|
||||
startExamModal.show();
|
||||
}
|
||||
|
||||
// Setup button for continuing an existing exam
|
||||
function setupContinueExamButton() {
|
||||
const modalTitle = document.getElementById('startExamModalLabel');
|
||||
|
||||
// Hide default content
|
||||
document.getElementById('newExamContent').style.display = 'none';
|
||||
|
||||
if (examInfo.info.events.examEndTime) {
|
||||
// Exam is finished - show completed content
|
||||
document.getElementById('examCompletedContent').style.display = 'block';
|
||||
document.getElementById('examInProgressContent').style.display = 'none';
|
||||
|
||||
// Update modal title
|
||||
modalTitle.textContent = 'Exam Completed';
|
||||
startExamBtn.textContent = 'Continue to Session';
|
||||
} else {
|
||||
// Exam is in progress - show in-progress content
|
||||
document.getElementById('examInProgressContent').style.display = 'block';
|
||||
document.getElementById('examCompletedContent').style.display = 'none';
|
||||
|
||||
// Update modal title
|
||||
modalTitle.textContent = 'Exam in Progress';
|
||||
startExamBtn.textContent = 'Continue Session';
|
||||
}
|
||||
|
||||
// Remove countdown behavior and just continue to session
|
||||
startExamBtn.addEventListener('click', function() {
|
||||
startExamModal.hide();
|
||||
startExam();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Setup button for starting a new exam
|
||||
function setupNewExamButton() {
|
||||
// Show default content, hide others
|
||||
document.getElementById('newExamContent').style.display = 'block';
|
||||
document.getElementById('examInProgressContent').style.display = 'none';
|
||||
document.getElementById('examCompletedContent').style.display = 'none';
|
||||
|
||||
// Reset modal title
|
||||
document.getElementById('startExamModalLabel').textContent = 'Ready to Begin Your Exam';
|
||||
|
||||
// Initialize counter
|
||||
let countDown = 3;
|
||||
startExamBtn.innerHTML = `Start Exam (${countDown})`;
|
||||
|
||||
// Remove any existing click handlers
|
||||
startExamBtn.replaceWith(startExamBtn.cloneNode(true));
|
||||
// Get the fresh reference after cloning
|
||||
const freshStartExamBtn = document.getElementById('startExamBtn');
|
||||
|
||||
// Add event listener to start exam button
|
||||
if (freshStartExamBtn) {
|
||||
freshStartExamBtn.addEventListener('click', function handleStartClick() {
|
||||
// Decrease counter on each click
|
||||
countDown--;
|
||||
|
||||
if (countDown > 0) {
|
||||
// Update button text with new counter
|
||||
freshStartExamBtn.innerHTML = `Start Exam (${countDown})`;
|
||||
} else {
|
||||
// Start exam when counter reaches 0
|
||||
freshStartExamBtn.removeEventListener('click', handleStartClick);
|
||||
startExamModal.hide();
|
||||
startExam();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track exam start event
|
||||
function trackExamStartEvent() {
|
||||
// Track exam start event only if this is a new exam (not a continuation)
|
||||
const examId = ExamApi.getExamId();
|
||||
if (examId && !examInfo.info?.events?.examStartTime) {
|
||||
const currentTime = Date.now();
|
||||
console.log('Setting exam start time:', currentTime);
|
||||
|
||||
ExamApi.trackExamEvent(examId, {
|
||||
examStartTime: currentTime,
|
||||
userAgent: navigator.userAgent,
|
||||
screenResolution: `${window.screen.width}x${window.screen.height}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track exam end event
|
||||
function trackExamEndEvent() {
|
||||
const examId = ExamApi.getExamId();
|
||||
if (!examId) return;
|
||||
|
||||
const currentEndTime = Date.now();
|
||||
console.log('Setting exam end time:', currentEndTime);
|
||||
|
||||
return ExamApi.trackExamEvent(examId, {
|
||||
examEndTime: currentEndTime
|
||||
}).catch(error => {
|
||||
console.error('Error tracking exam end event:', error);
|
||||
// Continue with exam evaluation regardless of tracking error
|
||||
});
|
||||
}
|
||||
|
||||
// Setup timer threshold notifications
|
||||
function setupTimerNotifications() {
|
||||
TimerService.registerTimeThresholdCallbacks({
|
||||
// 30 minutes remaining
|
||||
30: (minutes) => {
|
||||
UiUtils.showToast('30 minutes left in your exam', {
|
||||
bgColor: 'bg-warning',
|
||||
textColor: 'text-dark',
|
||||
delay: 7000
|
||||
});
|
||||
},
|
||||
// 10 minutes remaining
|
||||
10: (minutes) => {
|
||||
UiUtils.showToast('Only 10 minutes remaining! ', {
|
||||
bgColor: 'bg-danger',
|
||||
textColor: 'text-white',
|
||||
delay: 10000
|
||||
});
|
||||
},
|
||||
// Time's up
|
||||
0: (minutes) => {
|
||||
handleExamEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start exam functionality
|
||||
function startExam() {
|
||||
// Show loader while starting exam
|
||||
pageLoader.style.display = 'flex';
|
||||
|
||||
// Enter fullscreen mode
|
||||
UiUtils.requestFullscreen(document.documentElement);
|
||||
|
||||
// Acquire wake lock to prevent device from sleeping
|
||||
WakeLockService.acquireWakeLock()
|
||||
.then(success => {
|
||||
if (success) {
|
||||
console.log('Wake lock acquired successfully');
|
||||
} else {
|
||||
console.warn('Wake lock not acquired, device may sleep during exam');
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to Remote Desktop
|
||||
RemoteDesktopService.connectToRemoteDesktop(vncFrame, showVncConnectionStatus);
|
||||
|
||||
// Calculate remaining time
|
||||
const remainingTime = TimerService.calculateRemainingTime(examInfo);
|
||||
|
||||
// Handle timer visibility and initialization
|
||||
if (remainingTime <= 0 || examInfo.info?.events?.examEndTime) {
|
||||
// Hide timer if time is up or exam has ended
|
||||
examTimer.style.display = 'none';
|
||||
} else {
|
||||
// Show and initialize timer with remaining time
|
||||
examTimer.style.display = 'block';
|
||||
|
||||
// Initialize timer with DOM element
|
||||
TimerService.initTimer(examTimer, remainingTime, {
|
||||
onTimerEnd: () => {
|
||||
handleExamEnd();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up timer notifications
|
||||
setupTimerNotifications();
|
||||
|
||||
// Start the timer
|
||||
TimerService.startTimer();
|
||||
}
|
||||
|
||||
// Track exam start
|
||||
trackExamStartEvent();
|
||||
|
||||
// Hide loader after a short delay
|
||||
setTimeout(() => {
|
||||
pageLoader.style.display = 'none';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Handle exam end when timer reaches zero
|
||||
function handleExamEnd() {
|
||||
// Release wake lock as exam is ending
|
||||
WakeLockService.releaseWakeLock()
|
||||
.then(success => {
|
||||
if (success) {
|
||||
console.log('Wake lock released successfully');
|
||||
}
|
||||
});
|
||||
|
||||
// Show the exam end modal
|
||||
examEndModal.show();
|
||||
|
||||
// Get exam ID
|
||||
const examId = ExamApi.getExamId();
|
||||
if (!examId) return;
|
||||
|
||||
// Track exam end event
|
||||
trackExamEndEvent()
|
||||
.then(() => {
|
||||
// Make API call to end and evaluate the exam
|
||||
return ExamApi.evaluateExam(examId);
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Exam evaluation started:', data);
|
||||
// Set up the View Results button
|
||||
if (viewResultsBtn) {
|
||||
viewResultsBtn.addEventListener('click', () => {
|
||||
window.location.href = `/results?id=${examId}`;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error ending exam:', error);
|
||||
alert('There was an error evaluating your exam. Please try manually ending the exam.');
|
||||
});
|
||||
}
|
||||
|
||||
// Update question content
|
||||
function updateQuestionContent(questionId) {
|
||||
const question = questions.find(q => q.id === questionId || q.id === questionId.toString());
|
||||
|
||||
if (!question) {
|
||||
console.error(`Question with ID ${questionId} not found`);
|
||||
questionContent.innerHTML = '<div class="alert alert-danger">Question not found. Please try another question.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add fade effect
|
||||
questionContent.classList.add('content-fade');
|
||||
|
||||
// Use requestAnimationFrame for a smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
// Short timeout for the transition to take effect
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Get formatted content from QuestionService
|
||||
const formattedContent = QuestionService.generateQuestionContent(question);
|
||||
|
||||
// Update content
|
||||
questionContent.innerHTML = formattedContent;
|
||||
|
||||
// Hide action buttons in completed exam review mode
|
||||
if (isCompletedExamMode) {
|
||||
const actionButtonsContainer = document.querySelector('.action-buttons-container');
|
||||
if (actionButtonsContainer) {
|
||||
actionButtonsContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Add functionality to flag button
|
||||
const flagQuestionBtn = document.getElementById('flagQuestionBtn');
|
||||
if (flagQuestionBtn) {
|
||||
flagQuestionBtn.addEventListener('click', function() {
|
||||
toggleQuestionFlag(questionId);
|
||||
});
|
||||
}
|
||||
|
||||
// Add functionality to next button
|
||||
const nextQuestionBtn = document.getElementById('nextQuestionBtn');
|
||||
if (nextQuestionBtn) {
|
||||
nextQuestionBtn.addEventListener('click', function() {
|
||||
const currentIndex = questions.findIndex(q => q.id === currentQuestionId || q.id === currentQuestionId.toString());
|
||||
if (currentIndex < questions.length - 1) {
|
||||
currentQuestionId = questions[currentIndex + 1].id;
|
||||
updateQuestionContent(currentQuestionId);
|
||||
updateNavigationButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update dropdown button text
|
||||
questionDropdown.textContent = question.title || `Question ${questionId}`;
|
||||
|
||||
// Add subtle transition indicator
|
||||
questionContent.classList.add('question-transition');
|
||||
|
||||
// Remove fade effect
|
||||
requestAnimationFrame(() => {
|
||||
questionContent.classList.remove('content-fade');
|
||||
|
||||
// Remove transition indicator after animation completes
|
||||
setTimeout(() => {
|
||||
questionContent.classList.remove('question-transition');
|
||||
}, 1000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating question content:', error);
|
||||
questionContent.innerHTML = '<div class="alert alert-danger">Error displaying question content. Please try refreshing the page.</div>';
|
||||
questionContent.classList.remove('content-fade');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle between VNC and Terminal views
|
||||
function toggleView() {
|
||||
const terminalContainer = document.querySelector('.terminal-container');
|
||||
|
||||
if (isTerminalActive) {
|
||||
// Switch to VNC
|
||||
sshTerminalContainer.style.display = 'none';
|
||||
terminalContainer.style.display = 'flex';
|
||||
toggleViewBtn.textContent = 'Switch to Terminal';
|
||||
isTerminalActive = false;
|
||||
} else {
|
||||
// Switch to Terminal
|
||||
terminalContainer.style.display = 'none';
|
||||
sshTerminalContainer.style.display = 'flex';
|
||||
toggleViewBtn.textContent = 'Switch to Remote Desktop';
|
||||
isTerminalActive = true;
|
||||
|
||||
// Show toast notification about real exam constraints
|
||||
UiUtils.showToast('Note: In the actual certification exam, only remote desktop access is available. Terminal access is provided here for practice convenience.', {
|
||||
bgColor: 'bg-info',
|
||||
textColor: 'text-white',
|
||||
delay: 8000
|
||||
});
|
||||
|
||||
// Initialize terminal if not already done
|
||||
if (!TerminalService.isInitialized()) {
|
||||
TerminalService.initTerminal(sshTerminalContainer, true);
|
||||
} else {
|
||||
// Resize terminal to fit container
|
||||
TerminalService.resizeTerminal(sshTerminalContainer);
|
||||
}
|
||||
|
||||
// Give a little time for the display change to take effect, then resize again
|
||||
setTimeout(() => {
|
||||
TerminalService.resizeTerminal(sshTerminalContainer);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the exam UI
|
||||
function initExamUI() {
|
||||
// Set the first question
|
||||
updateQuestionContent(currentQuestionId);
|
||||
|
||||
// Dynamically populate question dropdown with fetched questions
|
||||
updateQuestionDropdown();
|
||||
|
||||
// Update navigation buttons
|
||||
updateNavigationButtons();
|
||||
|
||||
// Setup Remote Desktop frame handlers
|
||||
RemoteDesktopService.setupRemoteDesktopFrameHandlers(vncFrame, showVncConnectionStatus);
|
||||
|
||||
// Setup UI event listeners
|
||||
setupUIEventListeners();
|
||||
|
||||
// Setup clipboard copy for inline code elements
|
||||
ClipboardService.setupInlineCodeCopy();
|
||||
|
||||
// If in completed exam mode, ensure the question pane is visible
|
||||
if (isCompletedExamMode) {
|
||||
console.log('Setting up completed exam review mode UI');
|
||||
|
||||
// Make sure question panel is visible and sized correctly
|
||||
const questionPanel = document.getElementById('questionPanel');
|
||||
if (questionPanel) {
|
||||
questionPanel.style.display = 'block';
|
||||
|
||||
// Initialize panel resizer if available to ensure question pane is correctly sized
|
||||
if (window.panelResizer) {
|
||||
setTimeout(() => {
|
||||
window.panelResizer.resetPanels();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Add read-only indicator to question panel
|
||||
const questionContent = document.getElementById('questionContent');
|
||||
if (questionContent) {
|
||||
const reviewBanner = document.createElement('div');
|
||||
reviewBanner.className = 'alert alert-info mb-3';
|
||||
reviewBanner.textContent = 'Review Mode: This exam has been completed. You can review questions and your environment.';
|
||||
|
||||
// Insert at the beginning of question content
|
||||
if (questionContent.firstChild) {
|
||||
questionContent.insertBefore(reviewBanner, questionContent.firstChild);
|
||||
} else {
|
||||
questionContent.appendChild(reviewBanner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup all UI event listeners
|
||||
function setupUIEventListeners() {
|
||||
// Setup fullscreen toggle button for page
|
||||
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
UiUtils.toggleFullscreen();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup fullscreen toggle button for VNC iframe
|
||||
const fullscreenVncBtn = document.getElementById('fullscreenVncBtn');
|
||||
if (fullscreenVncBtn) {
|
||||
fullscreenVncBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
// Request fullscreen for the VNC iframe
|
||||
UiUtils.requestFullscreen(vncFrame);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup reconnect button for VNC
|
||||
const reconnectVncBtn = document.getElementById('reconnectVncBtn');
|
||||
if (reconnectVncBtn) {
|
||||
reconnectVncBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
showVncConnectionStatus('Reconnecting to Remote Desktop...', 'info');
|
||||
RemoteDesktopService.connectToRemoteDesktop(vncFrame, showVncConnectionStatus);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup fullscreen toggle button for Terminal
|
||||
const fullscreenTerminalBtn = document.getElementById('fullscreenTerminalBtn');
|
||||
if (fullscreenTerminalBtn) {
|
||||
fullscreenTerminalBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
// Request fullscreen for the terminal container
|
||||
UiUtils.requestFullscreen(sshTerminalContainer);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup toggle view button
|
||||
if (toggleViewBtn) {
|
||||
toggleViewBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
toggleView();
|
||||
});
|
||||
}
|
||||
|
||||
// Add fullscreen change event listener to update UI accordingly
|
||||
document.addEventListener('fullscreenchange', () => UiUtils.updateFullscreenUI(fullscreenBtn));
|
||||
document.addEventListener('webkitfullscreenchange', () => UiUtils.updateFullscreenUI(fullscreenBtn));
|
||||
document.addEventListener('mozfullscreenchange', () => UiUtils.updateFullscreenUI(fullscreenBtn));
|
||||
document.addEventListener('MSFullscreenChange', () => UiUtils.updateFullscreenUI(fullscreenBtn));
|
||||
|
||||
// Setup resize terminal button
|
||||
const resizeTerminalBtn = document.getElementById('resizeTerminalBtn');
|
||||
if (resizeTerminalBtn) {
|
||||
resizeTerminalBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
// Use the globally exposed PanelResizer instance
|
||||
if (window.panelResizer) {
|
||||
window.panelResizer.resetPanels();
|
||||
}
|
||||
// Fallback: simulate a double-click on the divider
|
||||
else {
|
||||
const divider = document.getElementById('panelDivider');
|
||||
if (divider) {
|
||||
const dblClickEvent = new MouseEvent('dblclick', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window
|
||||
});
|
||||
divider.dispatchEvent(dblClickEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for navigation
|
||||
prevBtn.addEventListener('click', () => {
|
||||
const currentIndex = questions.findIndex(q => q.id === currentQuestionId || q.id === currentQuestionId.toString());
|
||||
if (currentIndex > 0) {
|
||||
currentQuestionId = questions[currentIndex - 1].id;
|
||||
updateQuestionContent(currentQuestionId);
|
||||
updateNavigationButtons();
|
||||
}
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
const currentIndex = questions.findIndex(q => q.id === currentQuestionId || q.id === currentQuestionId.toString());
|
||||
if (currentIndex < questions.length - 1) {
|
||||
currentQuestionId = questions[currentIndex + 1].id;
|
||||
updateQuestionContent(currentQuestionId);
|
||||
updateNavigationButtons();
|
||||
}
|
||||
});
|
||||
|
||||
// End exam button (dropdown option)
|
||||
endExamBtnDropdown.addEventListener('click', () => {
|
||||
confirmModal.show();
|
||||
});
|
||||
|
||||
// Confirm end exam
|
||||
confirmEndBtn.addEventListener('click', () => {
|
||||
pageLoader.style.display = 'flex';
|
||||
confirmModal.hide();
|
||||
|
||||
// Release wake lock as exam is ending
|
||||
WakeLockService.releaseWakeLock();
|
||||
|
||||
// Get exam ID
|
||||
const examId = ExamApi.getExamId();
|
||||
if (!examId) return;
|
||||
|
||||
// Track exam end event
|
||||
trackExamEndEvent()
|
||||
.then(() => {
|
||||
// Make API call to end and evaluate the exam
|
||||
return ExamApi.evaluateExam(examId);
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Exam evaluation started:', data);
|
||||
TimerService.stopTimer();
|
||||
|
||||
// Show loader for 3 seconds before redirecting
|
||||
setTimeout(() => {
|
||||
window.location.href = `/results?id=${examId}`;
|
||||
}, 3000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error ending exam:', error);
|
||||
pageLoader.style.display = 'none';
|
||||
alert('There was an error ending your exam. Please try again or contact support.');
|
||||
});
|
||||
});
|
||||
|
||||
// Terminate session button
|
||||
terminateSessionBtn.addEventListener('click', () => {
|
||||
terminateModal.show();
|
||||
});
|
||||
|
||||
// Confirm terminate session
|
||||
confirmTerminateBtn.addEventListener('click', () => {
|
||||
// Show loader
|
||||
pageLoader.style.display = 'flex';
|
||||
terminateModal.hide();
|
||||
|
||||
// Release wake lock as session is terminating
|
||||
WakeLockService.releaseWakeLock();
|
||||
|
||||
// Get exam ID
|
||||
const examId = ExamApi.getExamId();
|
||||
if (!examId) return;
|
||||
|
||||
// Make API call to terminate session
|
||||
ExamApi.terminateSession(examId)
|
||||
.then(data => {
|
||||
console.log('Session terminated successfully:', data);
|
||||
// Stop timer
|
||||
TimerService.stopTimer();
|
||||
// Redirect to main page
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error terminating session:', error);
|
||||
alert('Failed to terminate session. Please try again or contact support.');
|
||||
// Hide loader
|
||||
pageLoader.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Update question dropdown
|
||||
function updateQuestionDropdown() {
|
||||
QuestionService.updateQuestionDropdown(questions, questionDropdownMenu, currentQuestionId, (clickedQuestionId) => {
|
||||
currentQuestionId = clickedQuestionId;
|
||||
updateQuestionContent(currentQuestionId);
|
||||
updateNavigationButtons();
|
||||
});
|
||||
}
|
||||
|
||||
// Update navigation buttons (disabled state)
|
||||
function updateNavigationButtons() {
|
||||
const currentIndex = questions.findIndex(q => q.id === currentQuestionId || q.id === currentQuestionId.toString());
|
||||
|
||||
// Disable prev button if on first question
|
||||
prevBtn.disabled = currentIndex <= 0;
|
||||
prevBtn.classList.toggle('nav-arrow-disabled', currentIndex <= 0);
|
||||
|
||||
// Disable next button if on last question
|
||||
nextBtn.disabled = currentIndex >= questions.length - 1;
|
||||
nextBtn.classList.toggle('nav-arrow-disabled', currentIndex >= questions.length - 1);
|
||||
}
|
||||
|
||||
// Function to toggle flag for a question
|
||||
function toggleQuestionFlag(questionId) {
|
||||
const questionIndex = questions.findIndex(q => q.id === questionId || q.id === questionId.toString());
|
||||
if (questionIndex !== -1) {
|
||||
// Toggle flag
|
||||
questions[questionIndex].flagged = !questions[questionIndex].flagged;
|
||||
|
||||
// Update the UI
|
||||
updateQuestionContent(questionId);
|
||||
|
||||
// Update the dropdown display
|
||||
updateQuestionDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to clean up resources when page is unloaded
|
||||
function cleanupResources() {
|
||||
// Release wake lock
|
||||
WakeLockService.releaseWakeLock();
|
||||
|
||||
// Stop timer if running
|
||||
if (TimerService.isTimerActive) {
|
||||
TimerService.stopTimer();
|
||||
}
|
||||
|
||||
console.log('Resources cleaned up before page unload');
|
||||
}
|
||||
});
|
||||
77
app/public/js/feedback.js
Normal file
77
app/public/js/feedback.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Feedback module for CK-X Simulator
|
||||
* Handles displaying feedback prompts and notifications
|
||||
*/
|
||||
|
||||
// Wait for DOM to be loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Show feedback reminder after a delay
|
||||
setTimeout(function() {
|
||||
// Check if results have loaded
|
||||
const resultsContent = document.getElementById('resultsContent');
|
||||
if (resultsContent && resultsContent.style.display !== 'none') {
|
||||
showFeedbackReminder();
|
||||
} else {
|
||||
// If results haven't loaded yet, wait for them
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.target.style.display !== 'none') {
|
||||
showFeedbackReminder();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (resultsContent) {
|
||||
observer.observe(resultsContent, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 10 * 1000); // Show after 10 seconds
|
||||
});
|
||||
|
||||
/**
|
||||
* Display a toast notification prompting for feedback
|
||||
*/
|
||||
function showFeedbackReminder() {
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-notification';
|
||||
toast.innerHTML = `
|
||||
<div class="toast-content">
|
||||
<i class="fas fa-comment-dots toast-icon"></i>
|
||||
<div class="toast-message">
|
||||
<p><strong>Your opinion matters!</strong></p>
|
||||
<p>Please take a moment to share your feedback on CK-X</p>
|
||||
</div>
|
||||
<a href="https://forms.gle/Dac9ALQnQb2dH1mw8" target="_blank" class="toast-button">Give Feedback</a>
|
||||
<button class="toast-close">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Add close functionality
|
||||
toast.querySelector('.toast-close').addEventListener('click', function() {
|
||||
toast.style.animation = 'slideOut 0.5s ease forwards';
|
||||
setTimeout(function() {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Auto-close after 15 seconds
|
||||
setTimeout(function() {
|
||||
if (document.body.contains(toast)) {
|
||||
toast.style.animation = 'slideOut 0.5s ease forwards';
|
||||
setTimeout(function() {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
547
app/public/js/index.js
Normal file
547
app/public/js/index.js
Normal file
@@ -0,0 +1,547 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const startExamBtn = document.getElementById('startExamBtn');
|
||||
const pageLoader = document.getElementById('pageLoader');
|
||||
const loaderMessage = document.getElementById('loaderMessage');
|
||||
const examSelectionModal = new bootstrap.Modal(document.getElementById('examSelectionModal'));
|
||||
|
||||
// Form elements
|
||||
const examCategorySelect = document.getElementById('examCategory');
|
||||
const examNameSelect = document.getElementById('examName');
|
||||
const examDescription = document.getElementById('examDescription');
|
||||
const startSelectedExamBtn = document.getElementById('startSelectedExam');
|
||||
const viewPastResultsBtn = document.getElementById('viewPastResultsBtn');
|
||||
|
||||
// Hide View Results button by default - only show when current exam is EVALUATING or EVALUATED
|
||||
if (viewPastResultsBtn) {
|
||||
viewPastResultsBtn.closest('li').style.display = 'none';
|
||||
}
|
||||
|
||||
let labs = []; // Will store all labs fetched from the API
|
||||
let selectedLab = null; // Will store the currently selected lab
|
||||
|
||||
// Check for current exam status on page load
|
||||
checkCurrentExamStatus();
|
||||
|
||||
console.log('Loading labs on page load...');
|
||||
// Load labs data when the page loads
|
||||
fetchLabs(false);
|
||||
|
||||
// Function to check current exam status
|
||||
function checkCurrentExamStatus() {
|
||||
fetch('/facilitator/api/v1/exams/current')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
if (response.status !== 404) {
|
||||
console.error('Error checking current exam status:', response.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.id) {
|
||||
// Store current exam data in localStorage for the View Results functionality
|
||||
localStorage.setItem('currentExamData', JSON.stringify(data));
|
||||
|
||||
// Show View Results button only if status is EVALUATING or EVALUATED
|
||||
if (data.status === 'EVALUATING' || data.status === 'EVALUATED') {
|
||||
if (viewPastResultsBtn) {
|
||||
viewPastResultsBtn.closest('li').style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking current exam status:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Event listener for the "Start Exam" button
|
||||
startExamBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
console.log('Checking for active exam sessions...');
|
||||
// First check if there's any active exam
|
||||
fetch('/facilitator/api/v1/exams/current')
|
||||
.then(response => {
|
||||
if (response.status === 404) {
|
||||
console.log('No active exam found, proceeding with new exam');
|
||||
// No active exam, proceed as normal
|
||||
if (labs.length > 0) {
|
||||
console.log('Using pre-loaded labs data');
|
||||
examSelectionModal.show();
|
||||
} else {
|
||||
console.log('No pre-loaded labs data available, fetching now...');
|
||||
fetchLabs(true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Error checking current exam status:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.id) {
|
||||
console.log('Active exam found:', data.id, 'Status:', data.status);
|
||||
// Active exam found, show warning modal
|
||||
showActiveExamWarningModal(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking for active exam:', error);
|
||||
// Proceed anyway in case of error
|
||||
if (labs.length > 0) {
|
||||
examSelectionModal.show();
|
||||
} else {
|
||||
fetchLabs(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Function to show warning modal for active exam
|
||||
function showActiveExamWarningModal(examData) {
|
||||
// Create modal HTML
|
||||
const modalHTML = `
|
||||
<div class="modal fade" id="activeExamWarningModal" tabindex="-1" aria-labelledby="activeExamWarningModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded">
|
||||
<div class="modal-header bg-dark text-white rounded-top">
|
||||
<h5 class="modal-title text-white" id="activeExamWarningModalLabel">Active Exam Detected</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info">
|
||||
<p>You already have an active exam session:</p>
|
||||
<p><strong>${examData.info?.name || 'Unknown Exam'}</strong></p>
|
||||
<p class="mb-0">Only one active exam session can be present at a time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer rounded-bottom">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="continueSessionBtn">CONTINUE CURRENT SESSION</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="terminateAndProceedBtn">TERMINATE AND PROCEED</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add modal to DOM if it doesn't exist
|
||||
if (!document.getElementById('activeExamWarningModal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
}
|
||||
|
||||
// Get modal element and create Bootstrap modal
|
||||
const modalElement = document.getElementById('activeExamWarningModal');
|
||||
const warningModal = new bootstrap.Modal(modalElement);
|
||||
|
||||
// Show the modal
|
||||
warningModal.show();
|
||||
|
||||
// Remove any existing event listeners by cloning and replacing the buttons
|
||||
const oldTerminateBtn = document.getElementById('terminateAndProceedBtn');
|
||||
const newTerminateBtn = oldTerminateBtn.cloneNode(true);
|
||||
oldTerminateBtn.parentNode.replaceChild(newTerminateBtn, oldTerminateBtn);
|
||||
|
||||
const oldContinueBtn = document.getElementById('continueSessionBtn');
|
||||
const newContinueBtn = oldContinueBtn.cloneNode(true);
|
||||
oldContinueBtn.parentNode.replaceChild(newContinueBtn, oldContinueBtn);
|
||||
|
||||
// Add event listener for continue session button
|
||||
document.getElementById('continueSessionBtn').addEventListener('click', function() {
|
||||
// Redirect to the current exam
|
||||
window.location.href = `/exam.html?id=${examData.id}`;
|
||||
});
|
||||
|
||||
// Add event listener for terminate and proceed button
|
||||
document.getElementById('terminateAndProceedBtn').addEventListener('click', function() {
|
||||
// Update button to show progress
|
||||
const terminateBtn = document.getElementById('terminateAndProceedBtn');
|
||||
terminateBtn.disabled = true;
|
||||
terminateBtn.innerHTML = '<div class="d-flex align-items-center justify-content-center"><span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span><span>TERMINATING...</span></div>';
|
||||
|
||||
// Show loading overlay
|
||||
showLoadingOverlay();
|
||||
updateLoadingMessage('Terminating active session...');
|
||||
|
||||
console.log('Attempting to terminate exam:', examData.id);
|
||||
// Call API to terminate the active exam
|
||||
fetch(`/facilitator/api/v1/exams/${examData.id}/terminate`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.error('Termination failed with status:', response.status);
|
||||
throw new Error('Failed to terminate exam. Status: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
console.log('Exam terminated successfully:', examData.id);
|
||||
// Hide the warning modal
|
||||
warningModal.hide();
|
||||
|
||||
// Clear any stored exam data
|
||||
localStorage.removeItem('currentExamData');
|
||||
localStorage.removeItem('currentExamId');
|
||||
|
||||
// Proceed with starting a new exam
|
||||
hideLoadingOverlay();
|
||||
if (labs.length > 0) {
|
||||
examSelectionModal.show();
|
||||
} else {
|
||||
fetchLabs(true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error terminating exam:', error);
|
||||
hideLoadingOverlay();
|
||||
|
||||
// Reset button state
|
||||
terminateBtn.disabled = false;
|
||||
terminateBtn.innerHTML = 'Terminate and Proceed';
|
||||
|
||||
alert('Failed to terminate the active exam. Please try again later.');
|
||||
});
|
||||
|
||||
// Clean up the modal when it's hidden
|
||||
modalElement.addEventListener('hidden.bs.modal', function() {
|
||||
console.log('Modal hidden, cleaning up event listeners');
|
||||
|
||||
// Remove event listeners by replacing buttons with clones if they exist
|
||||
if (document.getElementById('terminateAndProceedBtn')) {
|
||||
const oldTerminateBtn = document.getElementById('terminateAndProceedBtn');
|
||||
const newTerminateBtn = oldTerminateBtn.cloneNode(true);
|
||||
oldTerminateBtn.parentNode.replaceChild(newTerminateBtn, oldTerminateBtn);
|
||||
}
|
||||
|
||||
if (document.getElementById('continueSessionBtn')) {
|
||||
const oldContinueBtn = document.getElementById('continueSessionBtn');
|
||||
const newContinueBtn = oldContinueBtn.cloneNode(true);
|
||||
oldContinueBtn.parentNode.replaceChild(newContinueBtn, oldContinueBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch labs from the facilitator API
|
||||
function fetchLabs(showLoader = true) {
|
||||
console.log('Fetching labs, showLoader:', showLoader);
|
||||
if (showLoader) {
|
||||
pageLoader.style.display = 'flex';
|
||||
loaderMessage.textContent = 'Loading labs...';
|
||||
}
|
||||
|
||||
fetch('/facilitator/api/v1/assements/')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch labs. Status: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
labs = data;
|
||||
console.log('Labs loaded successfully, count:', labs.length);
|
||||
if (showLoader) {
|
||||
pageLoader.style.display = 'none';
|
||||
examSelectionModal.show();
|
||||
}
|
||||
populateLabCategories();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching labs:', error);
|
||||
if (showLoader) {
|
||||
pageLoader.style.display = 'none';
|
||||
alert('Failed to load labs. Please try again later.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Populate the lab categories dropdown
|
||||
function populateLabCategories() {
|
||||
// Get unique categories
|
||||
const categories = [...new Set(labs.map(lab => lab.category))];
|
||||
|
||||
// If CKAD is available, select it by default
|
||||
if (categories.includes('CKAD')) {
|
||||
examCategorySelect.value = 'CKAD';
|
||||
filterLabsByCategory('CKAD');
|
||||
} else if (categories.length > 0) {
|
||||
examCategorySelect.value = categories[0];
|
||||
filterLabsByCategory(categories[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter labs by category and populate the labs dropdown
|
||||
function filterLabsByCategory(category) {
|
||||
const filteredLabs = labs.filter(lab => lab.category === category);
|
||||
|
||||
// Clear existing options
|
||||
examNameSelect.innerHTML = '<option value="">Select a lab</option>';
|
||||
|
||||
// Add filtered labs to the dropdown
|
||||
filteredLabs.forEach(lab => {
|
||||
const option = document.createElement('option');
|
||||
option.value = lab.id;
|
||||
option.textContent = lab.name;
|
||||
examNameSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Enable the lab name select
|
||||
examNameSelect.disabled = false;
|
||||
|
||||
// If there are labs in this category, select the first one
|
||||
if (filteredLabs.length > 0) {
|
||||
examNameSelect.value = filteredLabs[0].id;
|
||||
updateLabDescription(filteredLabs[0]);
|
||||
} else {
|
||||
examDescription.textContent = 'No labs available for this category.';
|
||||
startSelectedExamBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the lab description when a lab is selected
|
||||
function updateLabDescription(lab) {
|
||||
// Create a nicely formatted description
|
||||
const difficultyText = lab.difficulty || 'Medium';
|
||||
const examTimeText = lab.examDurationInMinutes || lab.estimatedTime || '30';
|
||||
|
||||
const descriptionHTML = `
|
||||
<div class="exam-details">
|
||||
<p class="mb-0">${lab.description || 'No description available.'}</p>
|
||||
</div>
|
||||
<div class="exam-meta-container mt-3 pt-2 border-top">
|
||||
<div class="d-flex justify-content-start align-items-center">
|
||||
<div class="exam-meta me-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bar-chart-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2z"/>
|
||||
</svg>
|
||||
Difficulty: ${difficultyText}
|
||||
</div>
|
||||
<div class="exam-meta">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clock me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||
</svg>
|
||||
Exam Time: ${examTimeText} minutes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// No need to add styles dynamically anymore since they're in the CSS file
|
||||
|
||||
// Use innerHTML to render the HTML content
|
||||
examDescription.innerHTML = descriptionHTML;
|
||||
selectedLab = lab;
|
||||
startSelectedExamBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Event listener for the exam category select
|
||||
examCategorySelect.addEventListener('change', function() {
|
||||
filterLabsByCategory(this.value);
|
||||
});
|
||||
|
||||
// Event listener for the exam name select
|
||||
examNameSelect.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
const lab = labs.find(lab => lab.id === this.value);
|
||||
if (lab) {
|
||||
updateLabDescription(lab);
|
||||
}
|
||||
} else {
|
||||
examDescription.textContent = 'No lab selected.';
|
||||
selectedLab = null;
|
||||
startSelectedExamBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Event listener for the start selected exam button
|
||||
startSelectedExamBtn.addEventListener('click', function() {
|
||||
if (selectedLab) {
|
||||
examSelectionModal.hide();
|
||||
showLoadingOverlay(); // Show the loading overlay instead of pageLoader
|
||||
updateLoadingMessage('Starting lab environment...');
|
||||
updateExamInfo(`Lab: ${selectedLab.name} | Difficulty: ${selectedLab.difficulty || 'Medium'}`);
|
||||
|
||||
// Make a POST request to the facilitator API - using exams endpoint for POST
|
||||
fetch('/facilitator/api/v1/exams/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(selectedLab)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start lab. Status: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Store exam ID in localStorage
|
||||
localStorage.setItem('currentExamId', data.id);
|
||||
|
||||
// Start polling for status
|
||||
const warmUpTime = data.warmUpTimeInSeconds || 30;
|
||||
updateLoadingMessage(`Preparing your lab environment (${warmUpTime}s estimated)`);
|
||||
|
||||
// Poll for exam status until it's ready
|
||||
return pollExamStatus(data.id);
|
||||
})
|
||||
.then(() => {
|
||||
// Redirect to the lab page after status is READY
|
||||
const examId = localStorage.getItem('currentExamId');
|
||||
window.location.href = `/exam.html?id=${examId}`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error starting lab:', error);
|
||||
hideLoadingOverlay();
|
||||
alert('Failed to start the lab. Please try again later.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add new functions for exam status handling
|
||||
function showLoadingOverlay() {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideLoadingOverlay() {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateProgressBar(progress) {
|
||||
document.getElementById('progressBar').style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
function updateLoadingMessage(message) {
|
||||
document.getElementById('loadingMessage').textContent = message;
|
||||
}
|
||||
|
||||
function updateExamInfo(info) {
|
||||
document.getElementById('examInfo').textContent = info;
|
||||
}
|
||||
|
||||
async function pollExamStatus(examId) {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 1000; // Poll every 5 seconds
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
// set warmup time in seconds
|
||||
const warmUpTimeInSeconds = data.warmUpTimeInSeconds || 30;
|
||||
|
||||
if (data.status === 'READY') {
|
||||
// Set progress to 100% when ready
|
||||
updateProgressBar(100);
|
||||
updateLoadingMessage('Lab environment is ready! Redirecting...');
|
||||
// Wait a moment for the user to see the 100% progress
|
||||
setTimeout(() => resolve(data), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate progress based on warm-up time
|
||||
const elapsedTime = (Date.now() - startTime) / 1000;
|
||||
const progress = Math.min((elapsedTime / warmUpTimeInSeconds) * 100, 95);
|
||||
updateProgressBar(progress);
|
||||
updateLoadingMessage(data.message || 'Preparing lab environment...');
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, pollInterval);
|
||||
} catch (error) {
|
||||
console.error('Error polling exam status:', error);
|
||||
// Show error in the loading overlay
|
||||
updateLoadingMessage(`Error: ${error.message}. Retrying...`);
|
||||
// Continue polling despite errors
|
||||
setTimeout(poll, pollInterval);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
// Modify the existing startExam function
|
||||
async function startExam(examId) {
|
||||
try {
|
||||
showLoadingOverlay();
|
||||
updateLoadingMessage('Starting exam environment...');
|
||||
|
||||
const response = await fetch('/facilitator/api/v1/exams', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ examId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to start exam');
|
||||
}
|
||||
|
||||
// Store exam ID in localStorage
|
||||
localStorage.setItem('currentExamId', data.id);
|
||||
|
||||
// Start polling for status
|
||||
await pollExamStatus(data.id, data.warmUpTimeInSeconds || 30);
|
||||
|
||||
// Redirect to exam page when ready
|
||||
window.location.href = `/exam.html?id=${data.id}`;
|
||||
} catch (error) {
|
||||
console.error('Error starting exam:', error);
|
||||
hideLoadingOverlay();
|
||||
alert('Failed to start exam: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the dropdown population to include difficulty info
|
||||
function populateDropdown(labs) {
|
||||
const dropdown = document.getElementById('examDropdown');
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
labs.forEach(lab => {
|
||||
const option = document.createElement('option');
|
||||
option.value = lab.id;
|
||||
option.textContent = `${lab.name} (${lab.difficulty || 'Medium'})`;
|
||||
option.title = `${lab.description}\nDifficulty: ${lab.difficulty || 'Medium'}\nEstimated Time: ${lab.estimatedTime || '30'} minutes`;
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener for View Past Results button
|
||||
viewPastResultsBtn.addEventListener('click', function() {
|
||||
// Check if we have current exam data
|
||||
const currentExamDataStr = localStorage.getItem('currentExamData');
|
||||
|
||||
if (currentExamDataStr) {
|
||||
try {
|
||||
const currentExamData = JSON.parse(currentExamDataStr);
|
||||
|
||||
// If the current exam is evaluated or being evaluated, go directly to results
|
||||
if (currentExamData.status === 'EVALUATED' || currentExamData.status === 'EVALUATING') {
|
||||
window.location.href = `/results?id=${currentExamData.id}`;
|
||||
return;
|
||||
} else {
|
||||
// If the exam exists but isn't in the right state, show an alert
|
||||
alert('Exam results are not available yet. The exam must be evaluated first.');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing current exam data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no current exam at all, inform the user
|
||||
alert('No active exam found. Please start an exam first.');
|
||||
});
|
||||
});
|
||||
151
app/public/js/panel-resizer.js
Normal file
151
app/public/js/panel-resizer.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Panel Resizer - Handles resizing of panels by dragging
|
||||
*/
|
||||
class PanelResizer {
|
||||
constructor(options) {
|
||||
this.divider = document.getElementById(options.dividerId);
|
||||
this.leftPanel = document.getElementById(options.leftPanelId);
|
||||
this.rightPanel = document.getElementById(options.rightPanelId);
|
||||
this.container = document.getElementById(options.containerId) || this.leftPanel.parentElement;
|
||||
this.minLeftWidth = options.minLeftWidth || 200;
|
||||
this.minRightWidth = options.minRightWidth || 200;
|
||||
|
||||
this.isDragging = false;
|
||||
this.startX = 0;
|
||||
this.startLeftWidth = 0;
|
||||
|
||||
// Store initial panel width in local storage if it exists
|
||||
this.storageKey = options.storageKey || 'panelWidth';
|
||||
|
||||
// Add debug option
|
||||
this.debug = options.debug || false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.debug) console.log('Initializing panel resizer');
|
||||
|
||||
// Try to restore previous panel width from localStorage
|
||||
const savedWidth = localStorage.getItem(this.storageKey);
|
||||
if (savedWidth) {
|
||||
this.leftPanel.style.width = savedWidth;
|
||||
if (this.debug) console.log('Restored width from localStorage:', savedWidth);
|
||||
}
|
||||
|
||||
// Mouse events for desktop
|
||||
this.divider.addEventListener('mousedown', this.startDrag.bind(this));
|
||||
window.addEventListener('mousemove', this.drag.bind(this));
|
||||
window.addEventListener('mouseup', this.stopDrag.bind(this));
|
||||
|
||||
// Touch events for mobile
|
||||
this.divider.addEventListener('touchstart', this.startDrag.bind(this));
|
||||
window.addEventListener('touchmove', this.drag.bind(this));
|
||||
window.addEventListener('touchend', this.stopDrag.bind(this));
|
||||
|
||||
// Double click to reset
|
||||
this.divider.addEventListener('dblclick', this.resetPanels.bind(this));
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
// Exit if the divider doesn't exist
|
||||
if (!this.divider) return;
|
||||
|
||||
this.isDragging = true;
|
||||
this.divider.classList.add('dragging');
|
||||
|
||||
// Get the starting horizontal position
|
||||
this.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
|
||||
this.startLeftWidth = this.leftPanel.offsetWidth;
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.classList.add('no-select');
|
||||
|
||||
if (this.debug) {
|
||||
console.log('Drag started:', {
|
||||
startX: this.startX,
|
||||
startLeftWidth: this.startLeftWidth
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
// Calculate the new width based on mouse/touch position
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
|
||||
const deltaX = clientX - this.startX;
|
||||
|
||||
// Calculate container width
|
||||
const containerWidth = this.container.offsetWidth;
|
||||
|
||||
// Calculate new widths
|
||||
let newLeftWidth = this.startLeftWidth + deltaX;
|
||||
|
||||
// Apply min width constraints
|
||||
if (newLeftWidth < this.minLeftWidth) {
|
||||
newLeftWidth = this.minLeftWidth;
|
||||
} else if (containerWidth - newLeftWidth < this.minRightWidth) {
|
||||
newLeftWidth = containerWidth - this.minRightWidth;
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log('Dragging:', {
|
||||
clientX: clientX,
|
||||
deltaX: deltaX,
|
||||
containerWidth: containerWidth,
|
||||
newLeftWidth: newLeftWidth
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the new width
|
||||
this.leftPanel.style.width = `${newLeftWidth}px`;
|
||||
|
||||
// Prevent default behavior to avoid text selection
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
stopDrag() {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
this.divider.classList.remove('dragging');
|
||||
document.body.classList.remove('no-select');
|
||||
|
||||
// Save the current width to localStorage
|
||||
localStorage.setItem(this.storageKey, this.leftPanel.style.width);
|
||||
|
||||
if (this.debug) {
|
||||
console.log('Drag stopped. New width:', this.leftPanel.style.width);
|
||||
}
|
||||
}
|
||||
|
||||
resetPanels() {
|
||||
// Reset to default width (30%)
|
||||
this.leftPanel.style.width = '30%';
|
||||
localStorage.setItem(this.storageKey, '30%');
|
||||
|
||||
if (this.debug) {
|
||||
console.log('Panels reset to default width');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the panel resizer when the DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Create a global instance of PanelResizer
|
||||
window.panelResizer = new PanelResizer({
|
||||
dividerId: 'panelDivider',
|
||||
leftPanelId: 'questionPanel',
|
||||
rightPanelId: 'vncPanel',
|
||||
containerId: 'mainContainer',
|
||||
minLeftWidth: 200, // Minimum width for question panel
|
||||
minRightWidth: 300, // Minimum width for VNC panel
|
||||
storageKey: 'examPanelWidth',
|
||||
debug: true // Enable debug for troubleshooting
|
||||
});
|
||||
});
|
||||
465
app/public/js/results.js
Normal file
465
app/public/js/results.js
Normal file
@@ -0,0 +1,465 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const pageLoader = document.getElementById('pageLoader');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const errorText = document.getElementById('errorText');
|
||||
const retryButton = document.getElementById('retryButton');
|
||||
const resultsContent = document.getElementById('resultsContent');
|
||||
const examIdElement = document.getElementById('examId');
|
||||
const completedAtElement = document.getElementById('completedAt');
|
||||
const totalScoreElement = document.getElementById('totalScore');
|
||||
const totalPossibleScoreElement = document.getElementById('totalPossibleScore');
|
||||
const rankTextElement = document.getElementById('rankText');
|
||||
const rankBadgeElement = document.getElementById('rankBadge');
|
||||
const questionsContainer = document.getElementById('questionsContainer');
|
||||
const dashboardBtn = document.getElementById('dashboardBtn');
|
||||
const reEvaluateBtn = document.getElementById('reEvaluateBtn');
|
||||
const currentExamBtn = document.getElementById('currentExamBtn');
|
||||
const terminateBtn = document.getElementById('terminateBtn');
|
||||
const viewAnswersBtn = document.getElementById('viewAnswersBtn');
|
||||
|
||||
// Configuration for polling
|
||||
const POLLING_INTERVAL = 2000; // 5 seconds
|
||||
const MAX_POLLING_TIME = 100000; // 10 minutes (600 seconds)
|
||||
let pollingStartTime = 0;
|
||||
let pollingTimer = null;
|
||||
let currentExamId = null; // Store the current exam ID
|
||||
|
||||
// Add DOM elements for modal
|
||||
const terminateModal = document.getElementById('terminateModal');
|
||||
const closeModalBtn = document.getElementById('closeModalBtn');
|
||||
const cancelTerminateBtn = document.getElementById('cancelTerminateBtn');
|
||||
const confirmTerminateBtn = document.getElementById('confirmTerminateBtn');
|
||||
|
||||
// Add event listeners for action buttons
|
||||
dashboardBtn.addEventListener('click', function() {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
currentExamBtn.addEventListener('click', () => {
|
||||
if (currentExamId) {
|
||||
window.location.href = `/exam.html?id=${currentExamId}`;
|
||||
} else {
|
||||
showError('No exam ID available for redirection.');
|
||||
}
|
||||
});
|
||||
|
||||
viewAnswersBtn.addEventListener('click', () => {
|
||||
if (currentExamId) {
|
||||
window.location.href = `/answers.html?id=${currentExamId}`;
|
||||
} else {
|
||||
showError('No exam ID available for viewing answers.');
|
||||
}
|
||||
});
|
||||
|
||||
reEvaluateBtn.addEventListener('click', function() {
|
||||
if (currentExamId) {
|
||||
// Disable the button while re-evaluating
|
||||
reEvaluateBtn.disabled = true;
|
||||
reEvaluateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Evaluating...';
|
||||
|
||||
// Call the API to re-evaluate the exam
|
||||
initiateReEvaluation(currentExamId);
|
||||
} else {
|
||||
showError('No exam ID available for re-evaluation.');
|
||||
}
|
||||
});
|
||||
|
||||
terminateBtn.addEventListener('click', function() {
|
||||
if (!currentExamId) {
|
||||
showError('No exam ID available for termination.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show modal instead of confirm dialog
|
||||
terminateModal.style.display = 'flex';
|
||||
});
|
||||
|
||||
// Close modal when clicking the close button
|
||||
closeModalBtn.addEventListener('click', function() {
|
||||
terminateModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modal when clicking the cancel button
|
||||
cancelTerminateBtn.addEventListener('click', function() {
|
||||
terminateModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Handle confirm termination
|
||||
confirmTerminateBtn.addEventListener('click', function() {
|
||||
// Disable the button while terminating
|
||||
confirmTerminateBtn.disabled = true;
|
||||
confirmTerminateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i> Terminating...';
|
||||
|
||||
// Call the API to terminate the session
|
||||
fetch(`/facilitator/api/v1/exams/${currentExamId}/terminate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Session terminated:', data);
|
||||
// Redirect to dashboard after successful termination
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error terminating session:', error);
|
||||
confirmTerminateBtn.disabled = false;
|
||||
confirmTerminateBtn.innerHTML = '<i class="fas fa-power-off me-2"></i>Terminate Session';
|
||||
terminateModal.style.display = 'none';
|
||||
showError('Failed to terminate session: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal when clicking outside of it
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === terminateModal) {
|
||||
terminateModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Function to initiate re-evaluation
|
||||
function initiateReEvaluation(examId) {
|
||||
// Hide results and show loading
|
||||
resultsContent.style.display = 'none';
|
||||
pageLoader.style.display = 'flex';
|
||||
|
||||
// Update loader message
|
||||
updateLoaderMessage('Re-evaluation in progress...');
|
||||
|
||||
fetch(`/facilitator/api/v1/exams/${examId}/evaluate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Re-evaluation started:', data);
|
||||
|
||||
// Start polling for results
|
||||
startPolling(examId);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error starting re-evaluation:', error);
|
||||
reEvaluateBtn.disabled = false;
|
||||
reEvaluateBtn.innerHTML = '<i class="fas fa-sync-alt me-2"></i>Re-evaluate Exam';
|
||||
showError('Failed to start re-evaluation: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Get exam ID from URL
|
||||
function getExamId() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const examId = urlParams.get('id');
|
||||
|
||||
if (!examId) {
|
||||
showError('No exam ID provided. Please return to the dashboard.');
|
||||
return null;
|
||||
}
|
||||
|
||||
currentExamId = examId; // Store the exam ID for later use
|
||||
return examId;
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
};
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
pageLoader.style.display = 'none';
|
||||
errorText.textContent = message;
|
||||
errorMessage.style.display = 'block';
|
||||
resultsContent.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update loader message
|
||||
function updateLoaderMessage(message) {
|
||||
const loaderMessage = document.getElementById('loaderMessage');
|
||||
if (loaderMessage) {
|
||||
loaderMessage.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling for exam results
|
||||
function startPolling(examId) {
|
||||
// Save the start time for tracking elapsed time
|
||||
pollingStartTime = Date.now();
|
||||
|
||||
// Update UI to show evaluation in progress
|
||||
updateLoaderMessage('Evaluation in progress... (typically takes 2-3 minutes)');
|
||||
|
||||
// Start polling for status changes
|
||||
pollingTimer = setInterval(() => {
|
||||
checkExamStatus(examId);
|
||||
}, POLLING_INTERVAL);
|
||||
}
|
||||
|
||||
// Stop polling
|
||||
function stopPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress indicator
|
||||
function updateProgressIndicator(elapsedSeconds) {
|
||||
// Simplified - just show static message
|
||||
updateLoaderMessage('Evaluation in progress... (typically takes 2-3 minutes)');
|
||||
}
|
||||
|
||||
// Check exam status (for polling)
|
||||
function checkExamStatus(examId) {
|
||||
// Check if we've exceeded maximum polling time
|
||||
if (Date.now() - pollingStartTime > MAX_POLLING_TIME) {
|
||||
stopPolling();
|
||||
showError('Exam evaluation is taking longer than expected. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate elapsed time for tracking only (not displayed)
|
||||
const elapsedSeconds = Math.floor((Date.now() - pollingStartTime) / 1000);
|
||||
|
||||
// Check if exam status and results are available
|
||||
fetch(`/facilitator/api/v1/exams/${examId}/status`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// If evaluation is complete, fetch and display results
|
||||
if (data.status === 'EVALUATED') {
|
||||
stopPolling();
|
||||
fetchExamResults(examId);
|
||||
} else if (data.status === 'EVALUATING') {
|
||||
// Keep showing static message without progress updates
|
||||
updateLoaderMessage('Evaluation in progress... (typically takes 2-3 minutes)');
|
||||
} else if (data.status === 'EVALUATION_FAILED') {
|
||||
stopPolling();
|
||||
showError('Exam evaluation failed. Please contact support.');
|
||||
} else {
|
||||
// For any other status, show appropriate message
|
||||
updateLoaderMessage(`Waiting for evaluation to start... Current status: ${data.status}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking exam status:', error);
|
||||
// Don't stop polling on network errors - will retry on next interval
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch exam results from API
|
||||
function fetchExamResults(examId = null) {
|
||||
// If no exam ID provided, get it from the URL
|
||||
if (!examId) {
|
||||
examId = getExamId();
|
||||
if (!examId) {
|
||||
showError('No exam ID provided. Please return to the dashboard and try again.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Store current exam ID for later use
|
||||
currentExamId = examId;
|
||||
|
||||
// Show loader
|
||||
pageLoader.style.display = 'flex';
|
||||
updateLoaderMessage('Loading exam results...');
|
||||
|
||||
errorMessage.style.display = 'none';
|
||||
resultsContent.style.display = 'none';
|
||||
|
||||
const apiUrl = `/facilitator/api/v1/exams/${examId}/result`;
|
||||
|
||||
fetch(apiUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// If results not found, check status to see if we need to start polling
|
||||
return checkResultsStatus(examId);
|
||||
}
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.data) {
|
||||
renderExamResults(data.data);
|
||||
|
||||
// Reset the Re-evaluate button state if it was disabled
|
||||
if (reEvaluateBtn.disabled) {
|
||||
reEvaluateBtn.disabled = false;
|
||||
reEvaluateBtn.innerHTML = '<i class="fas fa-sync-alt me-2"></i>Re-evaluate Exam';
|
||||
}
|
||||
} else if (data && data.status === 'polling_started') {
|
||||
// This is a special status returned by checkResultsStatus
|
||||
// Polling has been started, so we just wait
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading exam results:', error);
|
||||
showError(error.message || 'Failed to load exam results');
|
||||
|
||||
// Reset the Re-evaluate button state if it was disabled
|
||||
if (reEvaluateBtn.disabled) {
|
||||
reEvaluateBtn.disabled = false;
|
||||
reEvaluateBtn.innerHTML = '<i class="fas fa-sync-alt me-2"></i>Re-evaluate Exam';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we need to poll for results
|
||||
function checkResultsStatus(examId) {
|
||||
return fetch(`/facilitator/api/v1/exams/${examId}/status`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'EVALUATING') {
|
||||
// Start polling if exam is being evaluated
|
||||
startPolling(examId);
|
||||
return { status: 'polling_started' };
|
||||
} else if (data.status === 'EVALUATION_FAILED') {
|
||||
throw new Error('Exam evaluation failed');
|
||||
} else if (data.status !== 'EVALUATED') {
|
||||
throw new Error(`Exam results not available. Current status: ${data.status}`);
|
||||
} else {
|
||||
// If status is EVALUATED but no results found, there might be a delay
|
||||
// Start polling anyway in case results are being finalized
|
||||
startPolling(examId);
|
||||
return { status: 'polling_started' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render exam results
|
||||
function renderExamResults(results) {
|
||||
// Update basic info
|
||||
examIdElement.textContent = `Exam ID: ${results.examId}`;
|
||||
completedAtElement.textContent = `Completed: ${formatDate(results.completedAt)}`;
|
||||
|
||||
// Update score
|
||||
totalScoreElement.textContent = results.totalScore;
|
||||
totalPossibleScoreElement.textContent = results.totalPossibleScore;
|
||||
|
||||
// Update rank
|
||||
rankTextElement.textContent = results.rank === 'high' ? 'High Score' :
|
||||
results.rank === 'medium' ? 'Medium Score' : 'Low Score';
|
||||
|
||||
// Apply rank class
|
||||
rankBadgeElement.className = 'rank-badge';
|
||||
rankBadgeElement.classList.add(`rank-${results.rank}`);
|
||||
|
||||
// Clear questions container
|
||||
questionsContainer.innerHTML = '';
|
||||
|
||||
// Add questions and verifications
|
||||
if (results.evaluationResults && results.evaluationResults.length > 0) {
|
||||
// Sort questions by ID (convert to number for proper numeric sorting)
|
||||
const sortedQuestions = [...results.evaluationResults].sort((a, b) => {
|
||||
const idA = parseInt(a.id);
|
||||
const idB = parseInt(b.id);
|
||||
return idA - idB;
|
||||
});
|
||||
|
||||
sortedQuestions.forEach(question => {
|
||||
// Sort verification steps by ID
|
||||
const sortedVerifications = [...question.verificationResults].sort((a, b) => {
|
||||
const idA = parseInt(a.id);
|
||||
const idB = parseInt(b.id);
|
||||
return idA - idB;
|
||||
});
|
||||
|
||||
// Calculate question score
|
||||
const questionScore = sortedVerifications.reduce((total, v) => total + v.score, 0);
|
||||
const questionPossibleScore = sortedVerifications.reduce((total, v) => total + v.weightage, 0);
|
||||
|
||||
// Create question card
|
||||
const questionCard = document.createElement('div');
|
||||
questionCard.className = 'question-card';
|
||||
|
||||
// Create question header
|
||||
const questionHeader = document.createElement('div');
|
||||
questionHeader.className = 'question-header';
|
||||
questionHeader.innerHTML = `
|
||||
<h3 class="question-title">Question ${question.id}</h3>
|
||||
<div class="question-score">${questionScore} of ${questionPossibleScore}</div>
|
||||
`;
|
||||
|
||||
// Create verification list
|
||||
const verificationList = document.createElement('ul');
|
||||
verificationList.className = 'verification-items';
|
||||
|
||||
// Add verification items
|
||||
sortedVerifications.forEach(verification => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'verification-item';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="verification-description">${verification.description}</div>
|
||||
<div class="verification-status ${verification.validAnswer ? 'status-success' : 'status-failure'}">
|
||||
${verification.validAnswer ?
|
||||
'<i class="fas fa-check-circle"></i>' :
|
||||
'<i class="fas fa-times-circle"></i>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
verificationList.appendChild(item);
|
||||
});
|
||||
|
||||
// Assemble question card
|
||||
questionCard.appendChild(questionHeader);
|
||||
questionCard.appendChild(verificationList);
|
||||
|
||||
// Add to container
|
||||
questionsContainer.appendChild(questionCard);
|
||||
});
|
||||
} else {
|
||||
questionsContainer.innerHTML = '<p>No evaluation results available</p>';
|
||||
}
|
||||
|
||||
// Hide loader and show content
|
||||
pageLoader.style.display = 'none';
|
||||
resultsContent.style.display = 'block';
|
||||
}
|
||||
|
||||
// Add retry button event listener
|
||||
retryButton.addEventListener('click', fetchExamResults);
|
||||
|
||||
// Start by fetching exam results
|
||||
fetchExamResults();
|
||||
|
||||
// Clean up when leaving the page
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopPolling();
|
||||
});
|
||||
});
|
||||
116
app/public/results.html
Normal file
116
app/public/results.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exam Results - CK-X Simulator</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="stylesheet" href="/css/results.css">
|
||||
<link rel="stylesheet" href="/css/feedback.css">
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Action buttons moved to top -->
|
||||
<div class="results-actions">
|
||||
<button id="dashboardBtn" class="btn btn-secondary">
|
||||
<i class="fas fa-home me-2"></i>Dashboard
|
||||
</button>
|
||||
<button id="currentExamBtn" class="btn btn-info">
|
||||
<i class="fas fa-tasks me-2"></i>Current Exam
|
||||
</button>
|
||||
<button id="viewAnswersBtn" class="btn btn-success">
|
||||
<i class="fas fa-check-square me-2"></i>View Answers
|
||||
</button>
|
||||
<button id="reEvaluateBtn" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt me-2"></i>Re-evaluate Exam
|
||||
</button>
|
||||
<button id="terminateBtn" class="btn btn-danger">
|
||||
<i class="fas fa-power-off me-2"></i>Terminate Session
|
||||
</button>
|
||||
<a href="https://forms.gle/Dac9ALQnQb2dH1mw8" target="_blank" class="btn btn-outline-primary" id="feedbackBtn">
|
||||
<i class="fas fa-comment-dots me-2"></i>Give Feedback
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<main class="results-container">
|
||||
<!-- Loading state -->
|
||||
<div id="pageLoader" class="page-loader">
|
||||
<div class="spinner"></div>
|
||||
<p id="loaderMessage">Loading exam results...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="errorMessage" class="error-message" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p id="errorText">An error occurred while loading the results.</p>
|
||||
<button id="retryButton" class="button">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Results content -->
|
||||
<div id="resultsContent" class="results-content" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Exam Results</h2>
|
||||
<div class="exam-info">
|
||||
<p id="examId">Exam ID: Loading...</p>
|
||||
<p id="completedAt">Completed: Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-summary">
|
||||
<div class="score-display">
|
||||
<h3>Solved subtasks:</h3>
|
||||
<div class="score-box">
|
||||
<span id="totalScore">0</span> / <span id="totalPossibleScore">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-display">
|
||||
<div id="rankBadge" class="rank-badge">
|
||||
<span id="rankText">Score</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="questionsContainer" class="questions-container">
|
||||
<!-- Questions will be added here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p style="text-align: center;">© <span id="currentYear"></span> CK-X Simulator.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/js/results.js"></script>
|
||||
<script src="/js/feedback.js"></script>
|
||||
<script>
|
||||
// Set the current year for copyright
|
||||
document.getElementById('currentYear').textContent = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<!-- Terminate Session Confirmation Modal -->
|
||||
<div id="terminateModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4>Terminate Session</h4>
|
||||
<button id="closeModalBtn" class="modal-close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to terminate this exam session?</p>
|
||||
<p>This will <strong>clear all your progress</strong> and your <strong>environment will be cleaned up</strong> immediately.</p>
|
||||
<p class="warning-text">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="cancelTerminateBtn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="confirmTerminateBtn" class="btn btn-danger">
|
||||
<i class="fas fa-power-off me-2"></i> Terminate Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script data-name="BMC-Widget" data-cfasync="false" src="https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js" data-id="nishan.b" data-description="Support me on Buy me a coffee!" data-message="CK-X helped you prep? A coffee helps it grow !!" data-color="#5F7FFF" data-position="Right" data-x_margin="18" data-y_margin="18"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
app/routes/api.js
Normal file
24
app/routes/api.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* API Routes module
|
||||
* Defines all API endpoints for the application
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* GET /api/vnc-info
|
||||
* Returns information about the VNC server
|
||||
*/
|
||||
router.get('/vnc-info', (req, res) => {
|
||||
res.json({
|
||||
host: config.VNC_SERVICE_HOST,
|
||||
port: config.VNC_SERVICE_PORT,
|
||||
wsUrl: `/websockify`,
|
||||
defaultPassword: config.VNC_PASSWORD,
|
||||
status: 'connected'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
238
app/server.js
Normal file
238
app/server.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const http = require('http');
|
||||
const socketio = require('socket.io');
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
// Server configuration
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// VNC service configuration from environment variables
|
||||
const VNC_SERVICE_HOST = process.env.VNC_SERVICE_HOST || 'remote-desktop-service';
|
||||
const VNC_SERVICE_PORT = process.env.VNC_SERVICE_PORT || 6901;
|
||||
const VNC_PASSWORD = process.env.VNC_PASSWORD || 'bakku-the-wizard'; // Default password
|
||||
|
||||
// SSH service configuration
|
||||
const SSH_HOST = process.env.SSH_HOST || 'remote-terminal'; // Use remote-terminal service
|
||||
const SSH_PORT = process.env.SSH_PORT || 22;
|
||||
const SSH_USER = process.env.SSH_USER || 'candidate';
|
||||
const SSH_PASSWORD = process.env.SSH_PASSWORD || 'password';
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = socketio(server);
|
||||
|
||||
// SSH terminal namespace
|
||||
const sshIO = io.of('/ssh');
|
||||
|
||||
// Handle SSH connections
|
||||
sshIO.on('connection', (socket) => {
|
||||
console.log('New SSH terminal connection established');
|
||||
|
||||
let ssh = new Client();
|
||||
|
||||
// Connect to the SSH server
|
||||
ssh.on('ready', () => {
|
||||
console.log('SSH connection established');
|
||||
|
||||
// Create shell session
|
||||
ssh.shell((err, stream) => {
|
||||
if (err) {
|
||||
console.error('SSH shell error:', err);
|
||||
socket.emit('data', `Error: ${err.message}\r\n`);
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle incoming data from SSH server
|
||||
stream.on('data', (data) => {
|
||||
socket.emit('data', data.toString('utf-8'));
|
||||
});
|
||||
|
||||
// Handle errors on stream
|
||||
stream.on('close', () => {
|
||||
console.log('SSH stream closed');
|
||||
ssh.end();
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
console.error('SSH stream error:', err);
|
||||
socket.emit('data', `Error: ${err.message}\r\n`);
|
||||
});
|
||||
|
||||
// Handle incoming data from browser
|
||||
socket.on('data', (data) => {
|
||||
stream.write(data);
|
||||
});
|
||||
|
||||
// Handle resize events
|
||||
socket.on('resize', (dimensions) => {
|
||||
if (dimensions && dimensions.cols && dimensions.rows) {
|
||||
stream.setWindow(dimensions.rows, dimensions.cols, 0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle socket disconnection
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Client disconnected from SSH terminal');
|
||||
stream.close();
|
||||
ssh.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Handle SSH connection errors
|
||||
ssh.on('error', (err) => {
|
||||
console.error('SSH connection error:', err);
|
||||
socket.emit('data', `SSH connection error: ${err.message}\r\n`);
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// Connect to SSH server
|
||||
ssh.connect({
|
||||
host: SSH_HOST,
|
||||
port: SSH_PORT,
|
||||
username: SSH_USER,
|
||||
password: SSH_PASSWORD,
|
||||
readyTimeout: 30000,
|
||||
keepaliveInterval: 10000
|
||||
});
|
||||
});
|
||||
|
||||
// Create the public directory if it doesn't exist
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
console.log('Created public directory');
|
||||
}
|
||||
|
||||
// Copy index.html to public directory if it doesn't exist
|
||||
const indexHtmlSrc = path.join(__dirname, 'index.html');
|
||||
const indexHtmlDest = path.join(publicDir, 'index.html');
|
||||
if (fs.existsSync(indexHtmlSrc) && !fs.existsSync(indexHtmlDest)) {
|
||||
fs.copyFileSync(indexHtmlSrc, indexHtmlDest);
|
||||
console.log('Copied index.html to public directory');
|
||||
}
|
||||
|
||||
// Enable CORS
|
||||
app.use(cors());
|
||||
|
||||
// Serve static files from the public directory
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Configure VNC proxy middleware
|
||||
const vncProxyConfig = {
|
||||
target: `http://${VNC_SERVICE_HOST}:${VNC_SERVICE_PORT}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
pathRewrite: {
|
||||
'^/vnc-proxy': ''
|
||||
},
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
// Log HTTP requests being proxied
|
||||
console.log(`Proxying HTTP request to VNC server: ${req.url}`);
|
||||
},
|
||||
onProxyReqWs: (proxyReq, req, socket, options, head) => {
|
||||
// Log WebSocket connections
|
||||
console.log(`WebSocket connection established to VNC server: ${req.url}`);
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// Log the responses from VNC server
|
||||
console.log(`Received response from VNC server for: ${req.url}`);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
console.error(`Proxy error: ${err.message}`);
|
||||
if (res && res.writeHead) {
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end(`Proxy error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to enhance VNC URLs with authentication if needed
|
||||
app.use('/vnc-proxy', (req, res, next) => {
|
||||
// Check if the URL already has a password parameter
|
||||
if (!req.query.password) {
|
||||
// If no password provided, add default password
|
||||
console.log('Adding default VNC password to request');
|
||||
const separator = req.url.includes('?') ? '&' : '?';
|
||||
req.url = `${req.url}${separator}password=${VNC_PASSWORD}`;
|
||||
}
|
||||
next();
|
||||
}, createProxyMiddleware(vncProxyConfig));
|
||||
|
||||
// Direct WebSocket proxy to handle the websockify endpoint
|
||||
app.use('/websockify', createProxyMiddleware({
|
||||
...vncProxyConfig,
|
||||
pathRewrite: {
|
||||
'^/websockify': '/websockify'
|
||||
},
|
||||
ws: true,
|
||||
onProxyReqWs: (proxyReq, req, socket, options, head) => {
|
||||
// Log WebSocket connections to websockify
|
||||
console.log(`WebSocket connection to websockify established: ${req.url}`);
|
||||
|
||||
// Add additional headers if needed
|
||||
proxyReq.setHeader('Origin', `http://${VNC_SERVICE_HOST}:${VNC_SERVICE_PORT}`);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
console.error(`Websockify proxy error: ${err.message}`);
|
||||
if (res && res.writeHead) {
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end(`Websockify proxy error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// API endpoint to get VNC server info
|
||||
app.get('/api/vnc-info', (req, res) => {
|
||||
res.json({
|
||||
host: VNC_SERVICE_HOST,
|
||||
port: VNC_SERVICE_PORT,
|
||||
wsUrl: `/websockify`,
|
||||
defaultPassword: VNC_PASSWORD,
|
||||
status: 'connected'
|
||||
});
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok', message: 'Service is healthy' });
|
||||
});
|
||||
|
||||
// Catch-all route to serve index.html for any other requests
|
||||
app.get('*', (req, res) => {
|
||||
// Special handling for exam page
|
||||
if (req.path === '/exam') {
|
||||
res.sendFile(path.join(__dirname, 'public', 'exam.html'));
|
||||
}
|
||||
// Special handling for results page
|
||||
else if (req.path === '/results') {
|
||||
res.sendFile(path.join(__dirname, 'public', 'results.html'));
|
||||
}
|
||||
else {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
res.status(500).sendFile(path.join(__dirname, 'public', '50x.html'));
|
||||
});
|
||||
|
||||
// Start the server
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`VNC proxy configured to ${VNC_SERVICE_HOST}:${VNC_SERVICE_PORT}`);
|
||||
console.log(`SSH service configured to ${SSH_HOST}:${SSH_PORT}`);
|
||||
});
|
||||
31
app/utils/staticFiles.js
Normal file
31
app/utils/staticFiles.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Static files utility
|
||||
* Handles the setup of necessary directories and files
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Sets up the necessary static file structure
|
||||
* Creates public directory if it doesn't exist
|
||||
* Copies index.html to public directory if needed
|
||||
*/
|
||||
function setupStaticFiles() {
|
||||
// Create the public directory if it doesn't exist
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
console.log('Created public directory');
|
||||
}
|
||||
|
||||
// Copy index.html to public directory if it doesn't exist
|
||||
const indexHtmlSrc = path.join(__dirname, '..', 'index.html');
|
||||
const indexHtmlDest = path.join(publicDir, 'index.html');
|
||||
if (fs.existsSync(indexHtmlSrc) && !fs.existsSync(indexHtmlDest)) {
|
||||
fs.copyFileSync(indexHtmlSrc, indexHtmlDest);
|
||||
console.log('Copied index.html to public directory');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = setupStaticFiles;
|
||||
235
compose-deploy.sh
Executable file
235
compose-deploy.sh
Executable file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ===============================================================================
|
||||
#
|
||||
# ██████╗██╗ ██╗ █████╗ ██████╗ ███████╗██╗███╗ ███╗██╗ ██╗██╗ █████╗ ████████╗ ██████╗ ██████╗
|
||||
# ██╔════╝██║ ██╔╝██╔══██╗██╔══██╗ ██╔════╝██║████╗ ████║██║ ██║██║ ██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
# ██║ █████╔╝ ███████║██║ ██║ ███████╗██║██╔████╔██║██║ ██║██║ ███████║ ██║ ██║ ██║██████╔╝
|
||||
# ██║ ██╔═██╗ ██╔══██║██║ ██║ ╚════██║██║██║╚██╔╝██║██║ ██║██║ ██╔══██║ ██║ ██║ ██║██╔══██╗
|
||||
# ╚██████╗██║ ██╗██║ ██║██████╔╝ ███████║██║██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝██║ ██║
|
||||
# ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
|
||||
#
|
||||
# ===============================================================================
|
||||
# Docker Compose CKAD Deployment Script
|
||||
# Version: 1.0.0
|
||||
# Author: Nishan B
|
||||
# ===============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Define colors for better readability
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Define symbols
|
||||
CHECK="${GREEN}✓${NC}"
|
||||
CROSS="${RED}✗${NC}"
|
||||
INFO="${BLUE}ℹ${NC}"
|
||||
WARN="${YELLOW}⚠${NC}"
|
||||
ARROW="${CYAN}➜${NC}"
|
||||
STAR="${PURPLE}★${NC}"
|
||||
CLOCK="${YELLOW}⏱${NC}"
|
||||
|
||||
# Define variables
|
||||
SCRIPT_START_TIME=$(date +%s)
|
||||
|
||||
# ===============================================================================
|
||||
# UTILITY FUNCTIONS
|
||||
# ===============================================================================
|
||||
|
||||
# Print timestamp
|
||||
print_timestamp() {
|
||||
echo -e "${GRAY}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
# Print section header
|
||||
print_header() {
|
||||
local title="$1"
|
||||
local title_length=${#title}
|
||||
local total_length=80
|
||||
local side_length=$(( (total_length - title_length - 2) / 2 ))
|
||||
local side_line=$(printf '%*s' "$side_length" | tr ' ' '═')
|
||||
|
||||
echo -e "\n${BOLD}${PURPLE}$side_line${NC} ${BOLD}${CYAN}$title${NC} ${BOLD}${PURPLE}$side_line${NC}\n"
|
||||
}
|
||||
|
||||
# Print success message
|
||||
print_success() {
|
||||
print_timestamp "${CHECK} ${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
# Print info message
|
||||
print_info() {
|
||||
print_timestamp "${INFO} $1"
|
||||
}
|
||||
|
||||
# Print warning message
|
||||
print_warning() {
|
||||
print_timestamp "${WARN} ${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Print error message
|
||||
print_error() {
|
||||
print_timestamp "${CROSS} ${RED}$1${NC}" >&2
|
||||
}
|
||||
|
||||
# Print progress
|
||||
print_progress() {
|
||||
echo -e " ${ARROW} ${GRAY}$1${NC}"
|
||||
}
|
||||
|
||||
# Error handler
|
||||
handle_error() {
|
||||
print_error "An error occurred at line $1"
|
||||
print_error "Deployment failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time() {
|
||||
local end_time=$(date +%s)
|
||||
local elapsed=$((end_time - SCRIPT_START_TIME))
|
||||
local minutes=$((elapsed / 60))
|
||||
local seconds=$((elapsed % 60))
|
||||
echo "${minutes}m ${seconds}s"
|
||||
}
|
||||
|
||||
# Setup error handling
|
||||
trap 'handle_error $LINENO' ERR
|
||||
|
||||
# ===============================================================================
|
||||
# MAIN SCRIPT
|
||||
# ===============================================================================
|
||||
|
||||
print_header "DEPLOYMENT STARTED"
|
||||
print_info "Starting deployment process for CKAD Simulator with Docker Compose"
|
||||
|
||||
# ===============================================================================
|
||||
# DOCKER IMAGE BUILDING
|
||||
# ===============================================================================
|
||||
|
||||
print_header "DOCKER IMAGE BUILDING"
|
||||
|
||||
print_progress "Building Docker images via Docker Compose..."
|
||||
COMPOSE_BAKE=true docker compose build
|
||||
print_success "All Docker images built successfully"
|
||||
|
||||
# ===============================================================================
|
||||
# DOCKER COMPOSE DEPLOYMENT
|
||||
# ===============================================================================
|
||||
|
||||
print_header "DOCKER COMPOSE DEPLOYMENT"
|
||||
|
||||
print_progress "Starting Docker Compose services..."
|
||||
docker compose up -d --remove-orphans
|
||||
print_success "All services started successfully"
|
||||
|
||||
# ===============================================================================
|
||||
# SERVICE AVAILABILITY CHECK
|
||||
# ===============================================================================
|
||||
|
||||
print_header "SERVICE AVAILABILITY CHECK"
|
||||
|
||||
print_progress "${CLOCK} Waiting for services to be ready..."
|
||||
sleep 15 # Give some time for services to start
|
||||
|
||||
# Check if the VNC service is running
|
||||
if docker compose ps remote-desktop | grep "Up"; then
|
||||
print_success "VNC service is running"
|
||||
else
|
||||
print_warning "VNC service may not be running properly"
|
||||
fi
|
||||
|
||||
# Check if the webapp service is running
|
||||
if docker compose ps webapp | grep "Up"; then
|
||||
print_success "Webapp service is running"
|
||||
else
|
||||
print_warning "Webapp service may not be running properly"
|
||||
fi
|
||||
|
||||
# Check if the Nginx service is running
|
||||
if docker compose ps nginx | grep "Up"; then
|
||||
print_success "Nginx service is running"
|
||||
else
|
||||
print_warning "Nginx service may not be running properly"
|
||||
fi
|
||||
|
||||
# Check if the jumphost service is running
|
||||
if docker compose ps jumphost | grep "Up"; then
|
||||
print_success "Jump host service is running"
|
||||
else
|
||||
print_warning "Jump host service may not be running properly"
|
||||
fi
|
||||
|
||||
# Check if the Kubernetes cluster service is running
|
||||
if docker compose ps k8s-api-server | grep "Up"; then
|
||||
print_success "Kubernetes cluster is running"
|
||||
|
||||
# Wait for the KIND cluster to be fully ready
|
||||
print_progress "${CLOCK} Waiting for Kubernetes cluster to be fully initialized..."
|
||||
sleep 30
|
||||
|
||||
# Check if cluster is accessible
|
||||
if docker compose exec k8s-api-server kind get clusters | grep "kind-cluster"; then
|
||||
print_success "KIND cluster is operational and accessible"
|
||||
else
|
||||
print_warning "KIND cluster may still be initializing"
|
||||
fi
|
||||
else
|
||||
print_warning "Kubernetes cluster may not be running properly"
|
||||
fi
|
||||
|
||||
# ===============================================================================
|
||||
# DEPLOYMENT SUMMARY
|
||||
# ===============================================================================
|
||||
|
||||
TOTAL_TIME=$(elapsed_time)
|
||||
|
||||
print_header 'DEPLOYMENT SUMMARY'
|
||||
echo -e "${STAR} ${GREEN}Deployment completed successfully!${NC}"
|
||||
echo -e "${INFO} ${CYAN}Environment:${NC} CKAD Simulator (Docker Compose)"
|
||||
echo -e "${INFO} ${CYAN}Services deployed:${NC} 5 (remote-desktop, webapp, nginx, jumphost, k8s-api-server)"
|
||||
echo -e "${INFO} ${CYAN}Total elapsed time:${NC} ${YELLOW}${TOTAL_TIME}${NC}"
|
||||
|
||||
echo -e "\n${STAR} ${GREEN}Your CKAD simulator is ready to use!${NC} ${STAR}\n"
|
||||
|
||||
# ===============================================================================
|
||||
# ACCESS INFORMATION
|
||||
# ===============================================================================
|
||||
|
||||
print_header "ACCESS INFORMATION"
|
||||
|
||||
# Get the host IP address
|
||||
HOST_IP=$(hostname -I | awk '{print $1}')
|
||||
if [ -z "$HOST_IP" ]; then
|
||||
HOST_IP="localhost"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}The following services are available:${NC}"
|
||||
echo -e "\n${STAR} ${GREEN}Access Simulator here:${NC} ${BOLD}http://${HOST_IP}:30080${NC}"
|
||||
|
||||
#open browser on host machine
|
||||
open http://${HOST_IP}:30080
|
||||
echo -e "${INFO} ${GRAY}Note: All other services (VNC, jumphost, K8s) are only accessible internally through the web application.${NC}"
|
||||
|
||||
# ===============================================================================
|
||||
# HELPFUL COMMANDS
|
||||
# ===============================================================================
|
||||
|
||||
print_header "HELPFUL COMMANDS"
|
||||
|
||||
echo -e "${CYAN}To stop the environment:${NC}"
|
||||
echo -e " ${GREEN}docker compose down --volumes --remove-orphans${NC}"
|
||||
|
||||
echo -e "\n${CYAN}To restart the environment:${NC}"
|
||||
echo -e " ${GREEN}docker compose restart${NC}"
|
||||
|
||||
echo -e "\n${CYAN}To view logs:${NC}"
|
||||
echo -e " ${GREEN}docker compose logs -f${NC}"
|
||||
245
compose.yaml
Normal file
245
compose.yaml
Normal file
@@ -0,0 +1,245 @@
|
||||
services:
|
||||
# VNC Server (Ubuntu)
|
||||
remote-desktop:
|
||||
image: nishanb/ck-x-simulator-remote-desktop:latest
|
||||
build:
|
||||
context: ./remote-desktop
|
||||
hostname: terminal
|
||||
expose:
|
||||
- "5901" # VNC port (internal only)
|
||||
- "6901" # Web VNC port (internal only)
|
||||
environment:
|
||||
- VNC_PW=bakku-the-wizard
|
||||
- VNC_PASSWORD=bakku-the-wizard
|
||||
- VNC_VIEW_ONLY=false
|
||||
- VNC_RESOLUTION=1280x800
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:6901/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
networks:
|
||||
- ckx-network
|
||||
|
||||
# Web Application (not directly exposed to users)
|
||||
webapp:
|
||||
image: nishanb/ck-x-simulator-webapp:latest
|
||||
build:
|
||||
context: ./app
|
||||
expose:
|
||||
- "3000" # Only exposed to internal network
|
||||
environment:
|
||||
- VNC_SERVICE_HOST=remote-desktop
|
||||
- VNC_SERVICE_PORT=6901
|
||||
- VNC_PASSWORD=bakku-the-wizard
|
||||
- SSH_HOST=remote-terminal
|
||||
- SSH_PORT=22
|
||||
- SSH_USER=candidate
|
||||
- SSH_PASSWORD=password
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.2'
|
||||
memory: 256M
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- ckx-network
|
||||
|
||||
# Nginx Reverse Proxy - ONLY SERVICE EXPOSED TO USERS
|
||||
nginx:
|
||||
image: nishanb/ck-x-simulator-nginx:latest
|
||||
build:
|
||||
context: ./nginx
|
||||
depends_on:
|
||||
- webapp
|
||||
- remote-desktop
|
||||
- remote-terminal
|
||||
- facilitator
|
||||
- k8s-api-server
|
||||
ports:
|
||||
- "30080:80" # Expose Nginx on port 30080
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.2'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.1'
|
||||
memory: 128M
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- ckx-network
|
||||
|
||||
# Jump Host (ckad9999 and ckad9988)
|
||||
jumphost:
|
||||
image: nishanb/ck-x-simulator-jumphost:latest
|
||||
build:
|
||||
context: ./jumphost
|
||||
hostname: ckad9999
|
||||
privileged: true
|
||||
# No external port mappings - only accessible internally
|
||||
expose:
|
||||
- "22" # SSH port (internal only)
|
||||
volumes:
|
||||
- kube-config:/home/candidate/.kube # Shared volume for Kubernetes config
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
networks:
|
||||
- ckx-network
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "22"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# Remote Terminal Service
|
||||
remote-terminal:
|
||||
image: nishanb/ck-x-simulator-remote-terminal:latest
|
||||
build:
|
||||
context: ./remote-terminal
|
||||
hostname: remote-terminal
|
||||
expose:
|
||||
- "22" # SSH port (internal only)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.2'
|
||||
memory: 256M
|
||||
networks:
|
||||
- ckx-network
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "22"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# KIND Kubernetes Cluster
|
||||
k8s-api-server: # Service name that will be used for DNS resolution
|
||||
image: nishanb/ck-x-simulator-cluster:latest
|
||||
build:
|
||||
context: ./kind-cluster
|
||||
container_name: kind-cluster
|
||||
hostname: k8s-api-server
|
||||
privileged: true # Required for running containers inside KIND
|
||||
expose:
|
||||
- "6443:6443"
|
||||
- "22"
|
||||
volumes:
|
||||
- kube-config:/home/candidate/.kube # Shared volume for Kubernetes config
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 4G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 2G
|
||||
networks:
|
||||
- ckx-network
|
||||
healthcheck:
|
||||
test: ["CMD", "ls", "/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 60s
|
||||
|
||||
# Redis Database for Facilitator
|
||||
redis:
|
||||
image: redis:alpine
|
||||
hostname: redis
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ckx-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.3'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.1'
|
||||
memory: 128M
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# Facilitator Service
|
||||
facilitator:
|
||||
image: nishanb/ck-x-simulator-facilitator:latest
|
||||
build:
|
||||
context: ./facilitator
|
||||
dockerfile: Dockerfile
|
||||
hostname: facilitator
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=prod
|
||||
- SSH_HOST=jumphost
|
||||
- SSH_PORT=22
|
||||
- SSH_USERNAME=candidate
|
||||
- LOG_LEVEL=info
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- TRACK_METRICS=true # based on the environment variable TRACK_METRICS, the facilitator will send metrics to the metric server
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- jumphost
|
||||
- redis
|
||||
networks:
|
||||
- ckx-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.2'
|
||||
memory: 256M
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
ckx-network:
|
||||
name: ckx-network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
kube-config: # Shared volume for Kubernetes configuration
|
||||
redis-data: # Persistent volume for Redis data
|
||||
42
docs/CONTRIBUTING.md
Normal file
42
docs/CONTRIBUTING.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Contributing to CK-X Simulator
|
||||
|
||||
Thank you for your interest in contributing! Here's how you can help:
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Fork and clone the [repository](https://github.com/nishanb/ck-x)
|
||||
2. Follow our [Development Setup Guide](docs/development-setup.md)
|
||||
3. Create a new branch for your changes
|
||||
4. Submit a Pull Request
|
||||
|
||||
## Community
|
||||
|
||||
- Join our [Telegram Community](https://t.me/ckxdev)
|
||||
- Star the repository if you find it helpful
|
||||
|
||||
## Important Rules
|
||||
|
||||
### No Plagiarism
|
||||
- Do not copy official exam questions
|
||||
- Create original practice scenarios
|
||||
- Focus on teaching concepts
|
||||
|
||||
### Lab Guidelines
|
||||
- Follow [Lab Creation Guide](docs/how-to-add-new-labs.md)
|
||||
- Include verification scripts
|
||||
- Test thoroughly
|
||||
- Provide clear instructions
|
||||
|
||||
### Code Quality
|
||||
- Follow existing code style
|
||||
- Write clear commit messages
|
||||
- Add tests for new features
|
||||
- Update documentation
|
||||
|
||||
## Questions?
|
||||
|
||||
Check our [FAQ](docs/FAQ.md) or join our [Telegram Community](https://t.me/ckxdev).
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the project's MIT License.
|
||||
30
docs/PRIVACY_POLICY.md
Normal file
30
docs/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Effective Date:** [Insert Date]
|
||||
|
||||
## 1. Introduction
|
||||
CK-X is an independent Kubernetes certification exam simulator. This Privacy Policy explains how we handle user data and ensure transparency regarding data collection and usage.
|
||||
|
||||
## 2. No Affiliation
|
||||
CK-X is **not affiliated with, endorsed by, or associated with the Cloud Native Computing Foundation (CNCF), the Linux Foundation, or PSI.** Any references to Kubernetes, CKAD, CKA, or CKS are for descriptive purposes only, and all trademarks belong to their respective owners.
|
||||
|
||||
## 3. Data Collection
|
||||
We collect minimal data necessary to improve CK-X. This may include:
|
||||
- Basic user preferences and settings.
|
||||
- Anonymous analytics for performance improvements.
|
||||
|
||||
We **do not** collect personal information, sell user data, or share it with third parties.
|
||||
|
||||
## 4. Use of Third-Party Tools
|
||||
CK-X may utilize open-source components and third-party tools, which are subject to their respective licenses. Users are responsible for reviewing those licenses where applicable.
|
||||
|
||||
## 5. No Guarantees
|
||||
CK-X is provided for educational purposes only. We do **not** guarantee:
|
||||
- That the content reflects actual CKAD, CKA, or CKS exam questions.
|
||||
- That using CK-X will result in passing any certification exam.
|
||||
|
||||
## 6. Question Content
|
||||
All questions in CK-X are either **originally created** for educational purposes or **contributed by community contributors**. We do **not copy or reproduce** actual exam questions from any official sources.
|
||||
|
||||
## 7. Policy Changes
|
||||
We may update this Privacy Policy from time to time. Continued use of CK-X after any changes implies acceptance of the updated terms.
|
||||
278
docs/development-setup.md
Normal file
278
docs/development-setup.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# CK-X Simulator: Development Setup Guide
|
||||
|
||||
This document provides step-by-step instructions for setting up and running the CK-X Simulator locally for development purposes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### System Requirements
|
||||
- **Operating System**: Linux, macOS, or Windows 10/11 with WSL2
|
||||
- **CPU**: 4+ cores recommended (minimum 2 cores)
|
||||
- **RAM**: 8GB minimum, 16GB recommended
|
||||
- **Storage**: 20GB free space
|
||||
|
||||
### Required Software
|
||||
- **Docker**: v20.10.0 or newer
|
||||
- **Docker Compose**: v2.0.0 or newer
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/ck-x-simulator.git
|
||||
cd ck-x-simulator
|
||||
```
|
||||
|
||||
### 2. Configure the Environment
|
||||
|
||||
Review the `compose.yaml` file to understand the service configuration. Key services include:
|
||||
|
||||
- **remote-desktop**: VNC server (Ubuntu)
|
||||
- **webapp**: Web Application frontend
|
||||
- **nginx**: Reverse proxy (only service exposed to users)
|
||||
- **jumphost**: SSH access host
|
||||
- **remote-terminal**: Remote terminal service
|
||||
- **k8s-api-server**: KIND Kubernetes cluster
|
||||
- **redis**: Redis database for Facilitator
|
||||
- **facilitator**: Backend service
|
||||
|
||||
### 3. Start the Services
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
This command will build and start all the services defined in the compose.yaml file.
|
||||
|
||||
## Accessing the Application
|
||||
|
||||
### 1. Web Interface
|
||||
|
||||
Once all services are running, access the web interface via Nginx:
|
||||
|
||||
```
|
||||
http://localhost:30080
|
||||
```
|
||||
|
||||
### 2. VNC Remote Desktop
|
||||
|
||||
The VNC server is not directly exposed outside the container network. To access it:
|
||||
|
||||
1. The web interface proxies VNC connections through Nginx
|
||||
2. Default VNC password: `bakku-the-wizard` (configured in compose.yaml)
|
||||
3. VNC resolution: 1280x800
|
||||
|
||||
### 3. SSH Access
|
||||
|
||||
SSH access is provided through the jumphost service:
|
||||
|
||||
- **Hostname**: ckad9999
|
||||
- **Username**: candidate
|
||||
- **Password**: password (configured in compose.yaml)
|
||||
|
||||
The SSH service is not directly exposed outside the container network and is accessed through the webapp.
|
||||
|
||||
## Service Details
|
||||
|
||||
### 1. Remote Desktop (VNC)
|
||||
|
||||
```yaml
|
||||
# From compose.yaml
|
||||
remote-desktop:
|
||||
image: nishanb/ck-x-simulator-remote-desktop:latest
|
||||
hostname: terminal
|
||||
expose:
|
||||
- "5901" # VNC port (internal only)
|
||||
- "6901" # Web VNC port (internal only)
|
||||
environment:
|
||||
- VNC_PW=bakku-the-wizard
|
||||
- VNC_PASSWORD=bakku-the-wizard
|
||||
- VNC_VIEW_ONLY=false
|
||||
- VNC_RESOLUTION=1280x800
|
||||
```
|
||||
|
||||
The remote desktop provides a graphical interface for the exam environment.
|
||||
|
||||
### 2. Web Application
|
||||
|
||||
```yaml
|
||||
# From compose.yaml
|
||||
webapp:
|
||||
image: nishanb/ck-x-simulator-webapp:latest
|
||||
expose:
|
||||
- "3000" # Only exposed to internal network
|
||||
environment:
|
||||
- VNC_SERVICE_HOST=remote-desktop
|
||||
- VNC_SERVICE_PORT=6901
|
||||
- VNC_PASSWORD=bakku-the-wizard
|
||||
- SSH_HOST=remote-terminal
|
||||
- SSH_PORT=22
|
||||
- SSH_USER=candidate
|
||||
- SSH_PASSWORD=password
|
||||
```
|
||||
|
||||
The web application serves as the frontend interface for the simulator.
|
||||
|
||||
### 3. Nginx (Reverse Proxy)
|
||||
|
||||
```yaml
|
||||
# From compose.yaml
|
||||
nginx:
|
||||
image: nishanb/ck-x-simulator-nginx:latest
|
||||
ports:
|
||||
- "30080:80" # Expose Nginx on port 30080
|
||||
```
|
||||
|
||||
Nginx is the only service directly exposed to users and handles routing to internal services.
|
||||
|
||||
### 4. Kubernetes Cluster
|
||||
|
||||
```yaml
|
||||
# From compose.yaml
|
||||
k8s-api-server:
|
||||
image: nishanb/ck-x-simulator-cluster:latest
|
||||
container_name: kind-cluster
|
||||
hostname: k8s-api-server
|
||||
privileged: true # Required for running containers inside KIND
|
||||
expose:
|
||||
- "6443:6443"
|
||||
- "22"
|
||||
volumes:
|
||||
- kube-config:/home/candidate/.kube # Shared volume for Kubernetes config
|
||||
```
|
||||
|
||||
The Kubernetes cluster runs in a KIND container with shared kube-config volume.
|
||||
|
||||
### 5. Facilitator Service
|
||||
|
||||
```yaml
|
||||
# From compose.yaml
|
||||
facilitator:
|
||||
image: nishanb/ck-x-simulator-facilitator:latest
|
||||
hostname: facilitator
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=prod
|
||||
- SSH_HOST=jumphost
|
||||
- SSH_PORT=22
|
||||
- SSH_USERNAME=candidate
|
||||
- LOG_LEVEL=info
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
```
|
||||
|
||||
The facilitator service handles backend operations and communicates with the Kubernetes cluster.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Modifying Services
|
||||
|
||||
To modify a service, edit its corresponding directory and then rebuild:
|
||||
|
||||
```bash
|
||||
# Edit files in the respective service directory
|
||||
# Then rebuild and restart the service
|
||||
docker-compose up --build <service-name>
|
||||
```
|
||||
|
||||
### 2. Inspecting Logs
|
||||
|
||||
```bash
|
||||
# View logs for all services
|
||||
docker-compose logs
|
||||
|
||||
# View logs for a specific service
|
||||
docker-compose logs <service-name>
|
||||
|
||||
# Follow logs
|
||||
docker-compose logs -f <service-name>
|
||||
```
|
||||
|
||||
### 3. Accessing Containers
|
||||
|
||||
```bash
|
||||
# Get shell access to a container
|
||||
docker-compose exec <service-name> bash
|
||||
|
||||
# Examples:
|
||||
docker-compose exec webapp bash
|
||||
docker-compose exec facilitator bash
|
||||
docker-compose exec k8s-api-server bash
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Container Startup Issues
|
||||
|
||||
If containers fail to start, check the logs:
|
||||
|
||||
```bash
|
||||
docker-compose logs <service-name>
|
||||
```
|
||||
|
||||
### 2. VNC Connection Issues
|
||||
|
||||
```bash
|
||||
# Check if VNC server is running
|
||||
docker-compose exec remote-desktop ps aux | grep vnc
|
||||
|
||||
# Restart VNC service
|
||||
docker-compose restart remote-desktop
|
||||
```
|
||||
|
||||
### 3. Kubernetes Cluster Issues
|
||||
|
||||
```bash
|
||||
# Check cluster status
|
||||
docker-compose exec k8s-api-server kubectl cluster-info
|
||||
|
||||
# Restart the cluster
|
||||
docker-compose restart k8s-api-server
|
||||
```
|
||||
|
||||
### 4. Resource Constraints
|
||||
|
||||
If your system cannot handle the resource requirements, adjust the limits in compose.yaml:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1' # Reduce CPU allocation
|
||||
memory: 1G # Reduce memory allocation
|
||||
```
|
||||
|
||||
## Network Architecture
|
||||
|
||||
All services are connected through the `ckx-network` bridge network:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
ckx-network:
|
||||
name: ckx-network
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
Services can communicate with each other using their service names as hostnames.
|
||||
|
||||
## Volume Management
|
||||
|
||||
The system uses persistent volumes for:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
kube-config: # Shared volume for Kubernetes configuration
|
||||
redis-data: # Persistent volume for Redis data
|
||||
```
|
||||
|
||||
## Reference Links
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Kubernetes Documentation](https://kubernetes.io/docs/)
|
||||
- [KIND Documentation](https://kind.sigs.k8s.io/)
|
||||
- [VNC Documentation](https://www.realvnc.com/en/connect/docs/)
|
||||
261
docs/how-to-add-new-labs.md
Normal file
261
docs/how-to-add-new-labs.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Contributing Labs to CK-X Simulator
|
||||
|
||||
This guide explains how to create and contribute your own practice labs for the CK-X Simulator. By following these steps, you can create custom assessment scenarios for Kubernetes certification preparation (CKAD, CKA, CKS) or other container-related topics.
|
||||
|
||||
## Lab Structure Overview
|
||||
|
||||
Each lab in CK-X Simulator consists of:
|
||||
|
||||
1. **Lab Entry** in the main labs registry
|
||||
2. **Configuration File** for lab settings
|
||||
3. **Assessment File** containing questions and verification steps
|
||||
4. **Setup and Verification Scripts** to prepare environments and validate student solutions
|
||||
5. **Answers File** with solution documentation
|
||||
|
||||
## Step 1: Create Lab Directory Structure
|
||||
|
||||
First, create a directory structure for your lab using this pattern:
|
||||
|
||||
```
|
||||
facilitator/
|
||||
└── assets/
|
||||
└── exams/
|
||||
└── [category]/
|
||||
└── [id]/
|
||||
├── config.json
|
||||
├── assessment.json
|
||||
├── answers.md
|
||||
└── scripts/
|
||||
├── setup/
|
||||
│ └── [setup scripts]
|
||||
└── validation/
|
||||
└── [verification scripts]
|
||||
```
|
||||
|
||||
Where:
|
||||
- `[category]` is the certification type (e.g., `ckad`, `cka`, `cks`, `other`)
|
||||
- `[id]` is a numeric identifier (e.g., `001`, `002`)
|
||||
|
||||
For example, to create a new CKAD lab with ID 003:
|
||||
```
|
||||
facilitator/assets/exams/ckad/003/
|
||||
```
|
||||
|
||||
## Step 2: Create Configuration File
|
||||
|
||||
Create a `config.json` file in your lab directory with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"lab": "ckad-003",
|
||||
"workerNodes": 1,
|
||||
"answers": "assets/exams/ckad/003/answers.md",
|
||||
"questions": "assessment.json",
|
||||
"totalMarks": 100,
|
||||
"lowScore": 40,
|
||||
"mediumScore": 60,
|
||||
"highScore": 90
|
||||
}
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `lab`: Unique identifier for the lab (should match directory structure)
|
||||
- `workerNodes`: Number of worker nodes required for this lab
|
||||
- `answers`: Path to answers markdown file
|
||||
- `questions`: Path to assessment JSON file
|
||||
- `totalMarks`: Maximum possible score
|
||||
- `lowScore`, `mediumScore`, `highScore`: Score thresholds for result categorization
|
||||
|
||||
## Step 3: Create Assessment File
|
||||
|
||||
Create an `assessment.json` file that defines questions, namespaces, and verification steps:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "1",
|
||||
"namespace": "default",
|
||||
"machineHostname": "node01",
|
||||
"question": "Create a deployment named `nginx-deploy` with 3 replicas using the nginx:1.19 image.\n\nEnsure the deployment is created in the `default` namespace.",
|
||||
"concepts": ["deployments", "replication"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Deployment exists",
|
||||
"verificationScriptFile": "q1_s1_validate_deployment.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Deployment has 3 replicas",
|
||||
"verificationScriptFile": "q1_s2_validate_replicas.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Deployment uses correct image",
|
||||
"verificationScriptFile": "q1_s3_validate_image.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
// Add more questions...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each question should include:
|
||||
- `id`: Unique question identifier
|
||||
- `namespace`: Kubernetes namespace for the question
|
||||
- `machineHostname`: The hostname to display for SSH connection
|
||||
- `question`: The actual task description with formatting:
|
||||
- Use `\n` for line breaks to improve readability
|
||||
- Put code references, commands, or file paths in backtick (e.g., `nginx:1.19`) which will be highlighted in the UI
|
||||
- Structure your question with clear paragraphs separated by blank lines
|
||||
- `concepts`: Array of concepts/topics covered
|
||||
- `verification`: Array of verification steps
|
||||
|
||||
Each verification step includes:
|
||||
- `id`: Unique step identifier
|
||||
- `description`: Human-readable description of what's being checked
|
||||
- `verificationScriptFile`: Script file path to validate the step (present in /scripts/validation directory)
|
||||
- `expectedOutput`: Expected return code (usually "0" for success)
|
||||
- `weightage`: Point value for this verification step
|
||||
|
||||
## Step 4: Create Setup and Verification Scripts
|
||||
|
||||
The CK-X Simulator uses two types of scripts:
|
||||
|
||||
### Setup Scripts
|
||||
|
||||
Create setup scripts in the `scripts/setup/` directory to prepare the environment for each question. These scripts run before the student starts the exam to ensure the necessary resources are available.
|
||||
|
||||
Example setup script (`scripts/setup/q1_setup.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Setup environment for Question 1
|
||||
|
||||
# Create namespace if it doesn't exist
|
||||
kubectl create namespace default --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Create any prerequisite resources
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nginx-config
|
||||
namespace: default
|
||||
data:
|
||||
nginx.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Environment setup complete for Question 1"
|
||||
exit 0
|
||||
```
|
||||
|
||||
### Verification Scripts
|
||||
|
||||
Create verification scripts in the `scripts/validation/` directory to validate student solutions. Each script should:
|
||||
|
||||
1. Check a specific aspect of the solution
|
||||
2. Return exit code 0 for success, non-zero for failure
|
||||
3. Output useful information for student feedback
|
||||
|
||||
Example verification script (`scripts/validation/q1_s1_validate_deployment.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Check if deployment exists
|
||||
|
||||
DEPLOYMENT_NAME="nginx-deploy"
|
||||
NAMESPACE="default"
|
||||
|
||||
kubectl get deployment $DEPLOYMENT_NAME -n $NAMESPACE &> /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Deployment '$DEPLOYMENT_NAME' exists in namespace '$NAMESPACE'"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Deployment '$DEPLOYMENT_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Step 5: Create Answers File
|
||||
|
||||
Create an `answers.md` file containing solutions to your questions. This file will be displayed directly to students when they view the exam answers.
|
||||
|
||||
Focus on providing clear, educational solutions with detailed explanations. The file is rendered as standard Markdown, so you can use all Markdown formatting features. Include complete solution commands, explanations of why certain approaches work, and any relevant tips or best practices.
|
||||
|
||||
For each question, provide the question text as a heading followed by a comprehensive solution that would help someone understand not just what to do but why that approach is correct.
|
||||
|
||||
## Step 6: Register Your Lab
|
||||
|
||||
Finally, add your lab to the main `labs.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"labs": [
|
||||
// ... existing labs ...
|
||||
{
|
||||
"id": "ckad-003",
|
||||
"assetPath": "assets/exams/ckad/003",
|
||||
"name": "CKAD Practice Lab - Advanced Deployments",
|
||||
"category": "CKAD",
|
||||
"description": "Practice advanced deployment patterns and strategies",
|
||||
"warmUpTimeInSeconds": 60,
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `id`: Unique identifier (should match directory structure)
|
||||
- `assetPath`: Path to lab resources
|
||||
- `name`: Display name for the lab
|
||||
- `category`: Lab category (CKAD, CKA, CKS, etc.)
|
||||
- `description`: Brief description of the lab content
|
||||
- `warmUpTimeInSeconds`: Preparation time before exam starts
|
||||
- `difficulty`: Difficulty level (easy, medium, hard)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Realistic Scenarios**: Design questions that mimic real certification exam tasks
|
||||
2. **Clear Instructions**: Write concise, unambiguous question descriptions
|
||||
3. **Thorough Verification**: Create scripts that verify all aspects of the solution
|
||||
4. **Comprehensive Answers**: Provide complete, educational solutions
|
||||
5. **Progressive Difficulty**: Arrange questions from simple to complex
|
||||
6. **Namespaces**: Use separate namespaces for different questions to avoid conflicts
|
||||
7. **Resource Requirements**: Keep resource requirements reasonable
|
||||
|
||||
## Testing Your Lab
|
||||
|
||||
Before submitting your lab:
|
||||
|
||||
1. Build and deploy the simulator with your new lab
|
||||
2. Go through each question as a student would
|
||||
3. Verify that all verification scripts work correctly
|
||||
4. Ensure the answers solve the questions as expected
|
||||
5. Check that scoring and evaluation work properly
|
||||
|
||||
## Contribution Process
|
||||
|
||||
1. Fork the CK-X Simulator repository
|
||||
2. Add your lab following these guidelines
|
||||
3. Test thoroughly
|
||||
4. Submit a pull request with a description of your lab
|
||||
|
||||
Thank you for contributing to the CK-X community!
|
||||
23
docs/local-setup-guide.md
Normal file
23
docs/local-setup-guide.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Local Setup Guide for CK-X Simulator
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/nishanb/ck-x.git
|
||||
cd ck-x
|
||||
```
|
||||
|
||||
2. Run the deployment script:
|
||||
```bash
|
||||
./scripts/compose-deploy.sh
|
||||
```
|
||||
|
||||
The script will deploy all services locally and open the application in your browser.
|
||||
|
||||
After making any changes to the code, you can redeploy with:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This setup has been tested on Mac and Linux environments.
|
||||
788
docs/webapp/exam-functionality.md
Normal file
788
docs/webapp/exam-functionality.md
Normal file
@@ -0,0 +1,788 @@
|
||||
# CK-X Simulator: exam.html Functionality Documentation
|
||||
|
||||
This document provides a detailed technical overview of the `exam.html` file, focusing on its structure, interactions, and API calls.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [HTML Structure](#html-structure)
|
||||
2. [Functionality Overview](#functionality-overview)
|
||||
3. [Component Interactions](#component-interactions)
|
||||
4. [API Integration](#api-integration)
|
||||
5. [Event Handlers](#event-handlers)
|
||||
6. [State Management](#state-management)
|
||||
7. [Error Handling](#error-handling)
|
||||
|
||||
## HTML Structure
|
||||
|
||||
The `exam.html` file serves as the main exam interface for the CK-X Simulator. Here's a detailed breakdown of its structure:
|
||||
|
||||
### 1. Head Section
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CK-X Simulator - Exam</title>
|
||||
<!-- External Dependencies -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/css/exam.css">
|
||||
</head>
|
||||
```
|
||||
|
||||
### 2. Body Components
|
||||
|
||||
#### Loader Component
|
||||
```html
|
||||
<div class="loader" id="pageLoader">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays during initial page load and environment setup
|
||||
- **State Management**: Controlled by `showLoader()` and `hideLoader()` functions
|
||||
|
||||
#### Toast Container
|
||||
```html
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" id="toastContainer">
|
||||
<!-- Toasts will be added here dynamically -->
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays notifications and alerts
|
||||
- **Dynamic Content**: Toasts added/removed based on events
|
||||
|
||||
#### Exam End Modal
|
||||
```html
|
||||
<div class="modal fade" id="examEndModal" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<!-- Modal content -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Handles exam completion
|
||||
- **Static Backdrop**: Prevents accidental dismissal
|
||||
- **Results Button**: Triggers results page navigation
|
||||
|
||||
#### Start Exam Modal
|
||||
```html
|
||||
<div class="modal fade" id="startExamModal" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<!-- Different content states -->
|
||||
<div class="modal-body" id="newExamContent">
|
||||
<!-- New exam content -->
|
||||
</div>
|
||||
<div class="modal-body" id="examInProgressContent">
|
||||
<!-- In-progress exam content -->
|
||||
</div>
|
||||
<div class="modal-body" id="examCompletedContent">
|
||||
<!-- Completed exam content -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Multiple States**: Handles different exam scenarios
|
||||
- **Static Backdrop**: Prevents accidental dismissal
|
||||
- **Dynamic Content**: Updates based on exam state
|
||||
|
||||
#### Header Section
|
||||
```html
|
||||
<div class="header">
|
||||
<div class="header-title-section">
|
||||
<div class="header-title">CK-X Simulator</div>
|
||||
<div class="timer" id="examTimer">120:00</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<!-- Dropdown menus -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Timer Display**: Shows remaining exam time
|
||||
- **Control Menus**: Interface, controls, and information options
|
||||
|
||||
#### Main Container
|
||||
```html
|
||||
<div class="main-container" id="mainContainer">
|
||||
<!-- Question Panel -->
|
||||
<div class="question-panel" id="questionPanel">
|
||||
<!-- Question navigation and content -->
|
||||
</div>
|
||||
|
||||
<!-- Resizable Divider -->
|
||||
<div class="panel-divider" id="panelDivider">
|
||||
<!-- Resize controls -->
|
||||
</div>
|
||||
|
||||
<!-- VNC Panel -->
|
||||
<div class="vnc-panel" id="vncPanel">
|
||||
<!-- Terminal and VNC containers -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Split Layout**: Questions and environment panels
|
||||
- **Resizable**: Adjustable panel sizes
|
||||
- **Dual Interface**: VNC and terminal options
|
||||
|
||||
## Functionality Overview
|
||||
|
||||
The `exam.html` file provides the core exam interface for the CK-X Simulator. Here's a detailed breakdown of its functionality:
|
||||
|
||||
### 1. Exam Interface Components
|
||||
|
||||
#### Header Controls
|
||||
1. **Exam Interface Dropdown**
|
||||
- Terminal resize options
|
||||
- Fullscreen toggle
|
||||
- View switching
|
||||
- Help resources
|
||||
|
||||
2. **Exam Controls Dropdown**
|
||||
- End exam option
|
||||
- Session termination
|
||||
- Emergency controls
|
||||
|
||||
3. **Exam Information Dropdown**
|
||||
- Exam instructions
|
||||
- Documentation links
|
||||
- Help resources
|
||||
|
||||
#### Main Interface
|
||||
1. **Question Panel**
|
||||
- Question navigation
|
||||
- Question content display
|
||||
- Progress tracking
|
||||
- Answer submission
|
||||
|
||||
2. **Environment Panel**
|
||||
- VNC connection
|
||||
- Terminal access
|
||||
- View switching
|
||||
- Fullscreen options
|
||||
|
||||
### 2. Exam Flow Management
|
||||
|
||||
#### Initial Setup
|
||||
1. **Session Validation**
|
||||
- Exam ID verification
|
||||
- Session state check
|
||||
- Environment preparation
|
||||
|
||||
2. **Interface Initialization**
|
||||
- Panel setup
|
||||
- Timer configuration
|
||||
- Connection establishment
|
||||
|
||||
3. **State Restoration**
|
||||
- Previous session recovery
|
||||
- Time remaining calculation
|
||||
- Question state restoration
|
||||
|
||||
#### During Exam
|
||||
1. **Timer Management**
|
||||
- Countdown tracking
|
||||
- Time warnings
|
||||
- Auto-submission
|
||||
|
||||
2. **Question Navigation**
|
||||
- Next/Previous controls
|
||||
- Question selection
|
||||
- Progress tracking
|
||||
|
||||
3. **Environment Control**
|
||||
- View switching
|
||||
- Panel resizing
|
||||
- Fullscreen management
|
||||
|
||||
### 3. State Management
|
||||
|
||||
#### Exam State
|
||||
- Timer tracking
|
||||
- Question progress
|
||||
- Answer submissions
|
||||
- Environment state
|
||||
|
||||
#### UI State
|
||||
- Panel sizes
|
||||
- View selection
|
||||
- Modal states
|
||||
- Loading states
|
||||
|
||||
### 4. API Integration
|
||||
|
||||
#### Endpoints
|
||||
1. **Exam Status**
|
||||
- `/facilitator/api/v1/exams/{id}`
|
||||
- Status updates
|
||||
- Time tracking
|
||||
|
||||
2. **Question Data**
|
||||
- `/facilitator/api/v1/exams/{id}/questions`
|
||||
- Question loading
|
||||
- Answer submission
|
||||
|
||||
3. **Environment Control**
|
||||
- `/facilitator/api/v1/exams/{id}/environment`
|
||||
- Connection management
|
||||
- State updates
|
||||
|
||||
### 5. Error Handling
|
||||
|
||||
#### Connection Issues
|
||||
- VNC connection failures
|
||||
- Terminal disconnections
|
||||
- API communication errors
|
||||
|
||||
#### State Recovery
|
||||
- Session restoration
|
||||
- Timer synchronization
|
||||
- Question state recovery
|
||||
|
||||
### 6. Security Features
|
||||
|
||||
#### Session Management
|
||||
- Secure connections
|
||||
- State validation
|
||||
- Access control
|
||||
|
||||
#### Data Protection
|
||||
- Secure communication
|
||||
- State encryption
|
||||
- Access restrictions
|
||||
|
||||
### 7. Performance Optimization
|
||||
|
||||
#### Resource Management
|
||||
- Efficient rendering
|
||||
- Connection pooling
|
||||
- State caching
|
||||
|
||||
#### UI Responsiveness
|
||||
- Smooth transitions
|
||||
- Non-blocking operations
|
||||
- Efficient updates
|
||||
|
||||
### 8. Browser Compatibility
|
||||
|
||||
#### Cross-browser Support
|
||||
- Modern browser features
|
||||
- Fallback mechanisms
|
||||
- Consistent rendering
|
||||
|
||||
#### Mobile Support
|
||||
- Responsive design
|
||||
- Touch controls
|
||||
- Adaptive layout
|
||||
|
||||
### 9. User Experience
|
||||
|
||||
#### Interface Feedback
|
||||
- Loading states
|
||||
- Progress indicators
|
||||
- Status messages
|
||||
|
||||
#### Navigation
|
||||
- Intuitive controls
|
||||
- Clear indicators
|
||||
- Easy access
|
||||
|
||||
### 10. Maintenance and Updates
|
||||
|
||||
#### Code Organization
|
||||
- Modular structure
|
||||
- Clear separation
|
||||
- Maintainable components
|
||||
|
||||
#### Future Enhancements
|
||||
- Feature addition points
|
||||
- Integration capabilities
|
||||
- Scalability options
|
||||
|
||||
## Component Interactions
|
||||
|
||||
### 1. Initial Page Load Sequence
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Browser
|
||||
participant UI
|
||||
participant API
|
||||
participant Environment
|
||||
|
||||
User->>Browser: Access exam.html
|
||||
Browser->>UI: DOMContentLoaded
|
||||
UI->>UI: Show Loader
|
||||
UI->>API: Get examId from URL
|
||||
API-->>UI: Return examId
|
||||
UI->>API: initializeExamSession(examId)
|
||||
API-->>UI: Return session data
|
||||
UI->>Environment: initializeEnvironment()
|
||||
Environment-->>UI: Environment ready
|
||||
UI->>UI: setupUIComponents()
|
||||
UI->>UI: startExamTimer()
|
||||
UI->>UI: Hide Loader
|
||||
UI-->>User: Display exam interface
|
||||
```
|
||||
|
||||
### 2. Exam Start Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant Environment
|
||||
participant Timer
|
||||
|
||||
User->>UI: Click "Start Exam"
|
||||
UI->>UI: showStartExamModal()
|
||||
User->>UI: Confirm start
|
||||
UI->>UI: updateModalContent('new')
|
||||
UI->>API: Create exam session
|
||||
API-->>UI: Session created
|
||||
UI->>Environment: Initialize VNC/Terminal
|
||||
Environment-->>UI: Connection established
|
||||
UI->>Timer: startExamTimer()
|
||||
Timer-->>UI: Timer running
|
||||
UI->>UI: Enter fullscreen mode
|
||||
UI-->>User: Exam interface ready
|
||||
```
|
||||
|
||||
### 3. Question Navigation Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
User->>UI: Navigate to question
|
||||
UI->>State: saveCurrentQuestion()
|
||||
UI->>API: loadQuestion(questionId)
|
||||
API-->>UI: Question data
|
||||
UI->>UI: displayQuestion()
|
||||
UI->>State: updateProgress()
|
||||
UI-->>User: Display new question
|
||||
```
|
||||
|
||||
### 4. Environment Control Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant VNC
|
||||
participant Terminal
|
||||
participant State
|
||||
|
||||
User->>UI: Switch view mode
|
||||
alt VNC Mode
|
||||
UI->>VNC: initializeVNC()
|
||||
VNC-->>UI: VNC ready
|
||||
UI->>Terminal: hide()
|
||||
else Terminal Mode
|
||||
UI->>Terminal: initializeTerminal()
|
||||
Terminal-->>UI: Terminal ready
|
||||
UI->>VNC: hide()
|
||||
end
|
||||
UI->>State: saveViewPreference()
|
||||
UI-->>User: View switched
|
||||
```
|
||||
|
||||
### 5. Panel Resizing Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant State
|
||||
|
||||
User->>UI: Start dragging divider
|
||||
UI->>UI: isResizing = true
|
||||
loop While dragging
|
||||
User->>UI: Mouse move
|
||||
UI->>UI: calculateNewWidth()
|
||||
UI->>UI: updatePanelSize()
|
||||
end
|
||||
User->>UI: Release mouse
|
||||
UI->>UI: isResizing = false
|
||||
UI->>State: savePanelSizes()
|
||||
UI-->>User: Panels resized
|
||||
```
|
||||
|
||||
### 6. Error Recovery Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant Environment
|
||||
participant State
|
||||
|
||||
Note over UI: Connection error detected
|
||||
UI->>UI: showError(message)
|
||||
UI->>Environment: attemptReconnect()
|
||||
alt Reconnection successful
|
||||
Environment-->>UI: Connection restored
|
||||
UI->>State: restoreState()
|
||||
UI-->>User: Recovery complete
|
||||
else Reconnection failed
|
||||
Environment-->>UI: Connection failed
|
||||
UI->>API: reportError()
|
||||
UI-->>User: Show recovery options
|
||||
end
|
||||
```
|
||||
|
||||
### 7. Exam End Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant Environment
|
||||
participant State
|
||||
|
||||
User->>UI: Click "End Exam"
|
||||
UI->>UI: showExamEndModal()
|
||||
User->>UI: Confirm end
|
||||
UI->>API: submitExam()
|
||||
API-->>UI: Submission successful
|
||||
UI->>State: clearExamState()
|
||||
UI->>Environment: cleanup()
|
||||
Environment-->>UI: Cleanup complete
|
||||
UI->>UI: redirectToResults()
|
||||
UI-->>User: Show results page
|
||||
```
|
||||
|
||||
### 8. State Management Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant State
|
||||
participant localStorage
|
||||
|
||||
Note over UI: Periodic state save
|
||||
UI->>State: saveExamState()
|
||||
State->>localStorage: setItem('examState')
|
||||
Note over UI: Page reload
|
||||
UI->>localStorage: getItem('examState')
|
||||
localStorage-->>UI: State data
|
||||
UI->>State: restoreExamState()
|
||||
State->>UI: Update UI components
|
||||
UI-->>User: State restored
|
||||
```
|
||||
|
||||
### 9. Timer Management Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant Timer
|
||||
participant State
|
||||
|
||||
UI->>Timer: startExamTimer()
|
||||
loop Every second
|
||||
Timer->>Timer: decrementTime()
|
||||
Timer->>UI: updateTimerDisplay()
|
||||
Timer->>State: saveTimeRemaining()
|
||||
alt Time <= 0
|
||||
Timer->>UI: triggerExamEnd()
|
||||
else Time <= warningThreshold
|
||||
Timer->>UI: showWarning()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 10. Environment Initialization Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant VNC
|
||||
participant Terminal
|
||||
participant State
|
||||
|
||||
UI->>API: requestEnvironment()
|
||||
API-->>UI: Environment config
|
||||
UI->>VNC: initializeVNC()
|
||||
VNC-->>UI: VNC ready
|
||||
UI->>Terminal: initializeTerminal()
|
||||
Terminal-->>UI: Terminal ready
|
||||
UI->>State: saveEnvironmentState()
|
||||
UI-->>User: Environment ready
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### 1. Exam Session Management
|
||||
```javascript
|
||||
async function initializeExamSession(examId) {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}`);
|
||||
if (!response.ok) throw new Error('Failed to load exam session');
|
||||
|
||||
const data = await response.json();
|
||||
setupExamSession(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error initializing exam session:', error);
|
||||
showError('Failed to load exam session');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Question Management
|
||||
```javascript
|
||||
async function loadQuestion(questionId) {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/questions/${questionId}`);
|
||||
if (!response.ok) throw new Error('Failed to load question');
|
||||
|
||||
const data = await response.json();
|
||||
displayQuestion(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error loading question:', error);
|
||||
showError('Failed to load question');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Environment Control
|
||||
```javascript
|
||||
async function initializeEnvironment() {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/environment`);
|
||||
if (!response.ok) throw new Error('Failed to initialize environment');
|
||||
|
||||
const data = await response.json();
|
||||
setupEnvironment(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error initializing environment:', error);
|
||||
showError('Failed to initialize environment');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
### 1. Modal Management
|
||||
```javascript
|
||||
// Show Exam End Modal
|
||||
function showExamEndModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('examEndModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Show Start Exam Modal
|
||||
function showStartExamModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('startExamModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Handle Modal State
|
||||
function updateModalContent(state) {
|
||||
const newExamContent = document.getElementById('newExamContent');
|
||||
const inProgressContent = document.getElementById('examInProgressContent');
|
||||
const completedContent = document.getElementById('examCompletedContent');
|
||||
|
||||
// Hide all content
|
||||
[newExamContent, inProgressContent, completedContent].forEach(el => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show relevant content
|
||||
switch (state) {
|
||||
case 'new':
|
||||
newExamContent.style.display = 'block';
|
||||
break;
|
||||
case 'in_progress':
|
||||
inProgressContent.style.display = 'block';
|
||||
break;
|
||||
case 'completed':
|
||||
completedContent.style.display = 'block';
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. View Management
|
||||
```javascript
|
||||
// Switch to Terminal View
|
||||
function switchToTerminal() {
|
||||
document.getElementById('vncPanel').style.display = 'none';
|
||||
document.getElementById('sshTerminalContainer').style.display = 'block';
|
||||
initializeTerminal();
|
||||
}
|
||||
|
||||
// Switch to VNC View
|
||||
function switchToVNC() {
|
||||
document.getElementById('sshTerminalContainer').style.display = 'none';
|
||||
document.getElementById('vncPanel').style.display = 'block';
|
||||
initializeVNC();
|
||||
}
|
||||
|
||||
// Toggle Fullscreen
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### 1. Exam State
|
||||
```javascript
|
||||
// Save Exam State
|
||||
function saveExamState() {
|
||||
const state = {
|
||||
currentQuestion: currentQuestionId,
|
||||
timeLeft: timeLeft,
|
||||
answers: submittedAnswers,
|
||||
environment: environmentState
|
||||
};
|
||||
|
||||
localStorage.setItem('examState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
// Load Exam State
|
||||
function loadExamState() {
|
||||
const state = localStorage.getItem('examState');
|
||||
if (state) {
|
||||
const parsedState = JSON.parse(state);
|
||||
restoreExamState(parsedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Exam State
|
||||
function clearExamState() {
|
||||
localStorage.removeItem('examState');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. UI State
|
||||
```javascript
|
||||
// Update UI State
|
||||
function updateUIState() {
|
||||
updateQuestionNavigation();
|
||||
updateTimerDisplay();
|
||||
updateEnvironmentStatus();
|
||||
updatePanelSizes();
|
||||
}
|
||||
|
||||
// Save UI Preferences
|
||||
function saveUIPreferences() {
|
||||
const preferences = {
|
||||
panelSizes: {
|
||||
question: document.getElementById('questionPanel').offsetWidth,
|
||||
environment: document.getElementById('vncPanel').offsetWidth
|
||||
},
|
||||
viewMode: currentViewMode,
|
||||
fullscreen: document.fullscreenElement !== null
|
||||
};
|
||||
|
||||
localStorage.setItem('uiPreferences', JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
// Load UI Preferences
|
||||
function loadUIPreferences() {
|
||||
const preferences = localStorage.getItem('uiPreferences');
|
||||
if (preferences) {
|
||||
const parsedPreferences = JSON.parse(preferences);
|
||||
applyUIPreferences(parsedPreferences);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Connection Error Handling
|
||||
```javascript
|
||||
// Handle VNC Connection Error
|
||||
function handleVNCError(error) {
|
||||
console.error('VNC Connection Error:', error);
|
||||
showError('Failed to connect to exam environment');
|
||||
|
||||
// Attempt recovery
|
||||
attemptVNCReconnect();
|
||||
}
|
||||
|
||||
// Handle Terminal Connection Error
|
||||
function handleTerminalError(error) {
|
||||
console.error('Terminal Connection Error:', error);
|
||||
showError('Failed to connect to terminal');
|
||||
|
||||
// Attempt recovery
|
||||
attemptTerminalReconnect();
|
||||
}
|
||||
|
||||
// Show Error Message
|
||||
function showError(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.innerHTML = `
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">Error</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">${message}</div>
|
||||
`;
|
||||
|
||||
document.getElementById('toastContainer').appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. State Recovery
|
||||
```javascript
|
||||
// Attempt State Recovery
|
||||
async function attemptStateRecovery() {
|
||||
try {
|
||||
// Check exam status
|
||||
const examStatus = await checkExamStatus();
|
||||
|
||||
if (examStatus.isActive) {
|
||||
// Restore exam state
|
||||
await restoreExamState(examStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('State Recovery Error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Session Timeout
|
||||
function handleSessionTimeout() {
|
||||
showError('Session timeout. Attempting to reconnect...');
|
||||
attemptStateRecovery();
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `exam.html` file provides a comprehensive exam interface for the CK-X Simulator, implementing robust features for exam delivery, environment management, and user interaction. The modular design ensures maintainability and extensibility while providing a secure and reliable exam experience.
|
||||
|
||||
Key aspects of the implementation include:
|
||||
- Split-panel interface for questions and environment
|
||||
- Real-time timer and progress tracking
|
||||
- Dual interface support (VNC and Terminal)
|
||||
- Comprehensive error handling and recovery
|
||||
- Secure session management
|
||||
- Responsive and adaptive design
|
||||
618
docs/webapp/index-functionality.md
Normal file
618
docs/webapp/index-functionality.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# CK-X Simulator: index.html Functionality Documentation
|
||||
|
||||
This document provides a detailed technical overview of the `index.html` file, focusing on its structure, interactions, and API calls.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [HTML Structure](#html-structure)
|
||||
2. [Functionality Overview](#functionality-overview)
|
||||
3. [Component Interactions](#component-interactions)
|
||||
4. [API Integration](#api-integration)
|
||||
5. [Event Handlers](#event-handlers)
|
||||
6. [State Management](#state-management)
|
||||
7. [Error Handling](#error-handling)
|
||||
|
||||
## HTML Structure
|
||||
|
||||
The `index.html` file serves as the main landing page for the CK-X Simulator. Here's a detailed breakdown of its structure:
|
||||
|
||||
### 1. Head Section
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>CK-X | Kubernetes Certification Simulator</title>
|
||||
<!-- External Dependencies -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/css/index.css">
|
||||
</head>
|
||||
```
|
||||
|
||||
### 2. Body Components
|
||||
|
||||
#### Loader Component
|
||||
```html
|
||||
<div class="loader" id="pageLoader">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="loader-message" id="loaderMessage">Lab is getting ready...</div>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays during API calls and lab initialization
|
||||
- **State Management**: Controlled by `showLoader()` and `hideLoader()` functions
|
||||
- **Message Updates**: Dynamic updates via `updateLoaderMessage()`
|
||||
|
||||
#### Navigation Bar
|
||||
```html
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">CK-X</a>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item me-3 view-results-btn-container" style="display: none;">
|
||||
<a class="nav-link" href="#" id="viewPastResultsBtn">
|
||||
<!-- SVG Icon -->
|
||||
View Result
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/nishanb/CKAD-X" target="_blank">
|
||||
<!-- GitHub Icon -->
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
```
|
||||
- **Dynamic Elements**: Results button visibility controlled by exam state
|
||||
- **Event Handling**: Results button click triggers navigation to results page
|
||||
|
||||
#### Hero Section
|
||||
```html
|
||||
<section class="hero-section full-height d-flex align-items-center text-center position-relative">
|
||||
<div class="container hero-content">
|
||||
<h1 class="display-4 mb-4">Kubernetes Certification Exam Simulator</h1>
|
||||
<p class="lead mb-5">Practice in a realistic environment...</p>
|
||||
<a href="#" class="btn btn-light btn-lg start-exam-btn" id="startExamBtn">START EXAM</a>
|
||||
</div>
|
||||
<div class="scroll-indicator">
|
||||
<p>SCROLL TO EXPLORE</p>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
- **Main CTA**: "START EXAM" button triggers exam selection flow
|
||||
- **Scroll Indicator**: Visual cue for content below
|
||||
|
||||
#### Features Section
|
||||
```html
|
||||
<section id="features">
|
||||
<div class="container">
|
||||
<div class="features-wrapper">
|
||||
<!-- Feature Cards -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
- **Static Content**: Displays six feature cards
|
||||
- **Responsive Layout**: Bootstrap grid system for responsive design
|
||||
|
||||
#### Loading Overlay
|
||||
```html
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-content">
|
||||
<h2>Preparing Your Lab Environment</h2>
|
||||
<div class="custom-progress-bar">
|
||||
<div class="custom-progress" id="progressBar"></div>
|
||||
</div>
|
||||
<div class="loading-message" id="loadingMessage">Initializing environment...</div>
|
||||
<div class="exam-info" id="examInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Progress Tracking**: Visual feedback during lab initialization
|
||||
- **Dynamic Updates**: Progress bar and message updates via API responses
|
||||
|
||||
#### Exam Selection Modal
|
||||
```html
|
||||
<div class="modal fade" id="examSelectionModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Select Your Exam</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="examSelectionForm">
|
||||
<!-- Exam Selection Form -->
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">CANCEL</button>
|
||||
<button type="button" class="btn btn-primary" id="startSelectedExam" disabled>START EXAM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Form Elements**: Dynamic exam selection options
|
||||
- **Validation**: Start button enabled only when valid selection made
|
||||
|
||||
## Functionality Overview
|
||||
|
||||
The `index.html` file serves as the main entry point for the CK-X Simulator, providing a comprehensive interface for exam selection and management. Here's a detailed breakdown of its functionality:
|
||||
|
||||
### 1. User Interface Flow
|
||||
|
||||
#### Initial Load
|
||||
1. **Page Initialization**
|
||||
- Checks for existing exam sessions
|
||||
- Preloads available labs data
|
||||
- Initializes UI components
|
||||
- Sets up event listeners
|
||||
|
||||
2. **Navigation Bar**
|
||||
- Displays CK-X branding
|
||||
- Shows/hides "View Result" button based on exam state
|
||||
- Provides GitHub repository link
|
||||
- Fixed position for easy access
|
||||
|
||||
3. **Hero Section**
|
||||
- Main landing area with exam simulator title
|
||||
- "START EXAM" call-to-action button
|
||||
- Scroll indicator for content discovery
|
||||
|
||||
4. **Features Section**
|
||||
- Displays six key features of the simulator
|
||||
- Responsive grid layout
|
||||
- Visual icons and descriptions
|
||||
|
||||
### 2. Exam Selection Process
|
||||
|
||||
#### Start Exam Flow
|
||||
1. **Initial Check**
|
||||
- Validates current exam status
|
||||
- Checks for active sessions
|
||||
- Verifies system requirements
|
||||
|
||||
2. **Exam Selection Modal**
|
||||
- Displays available exam categories
|
||||
- Shows exam descriptions
|
||||
- Validates user selection
|
||||
- Enables/disables start button
|
||||
|
||||
3. **Lab Environment Setup**
|
||||
- Shows loading overlay
|
||||
- Displays progress bar
|
||||
- Provides status updates
|
||||
- Handles initialization errors
|
||||
|
||||
### 3. State Management
|
||||
|
||||
#### Local Storage
|
||||
- Stores current exam data
|
||||
- Manages exam session state
|
||||
- Handles user preferences
|
||||
- Maintains UI state
|
||||
|
||||
#### UI State
|
||||
- Controls loading indicators
|
||||
- Manages modal visibility
|
||||
- Updates button states
|
||||
- Handles responsive layout
|
||||
|
||||
### 4. API Integration
|
||||
|
||||
#### Endpoints
|
||||
1. **Exam Status**
|
||||
- `/facilitator/api/v1/exams/current`
|
||||
- Checks active exam sessions
|
||||
- Returns exam details
|
||||
|
||||
2. **Labs Data**
|
||||
- `/facilitator/api/v1/assements/`
|
||||
- Fetches available labs
|
||||
- Updates exam options
|
||||
|
||||
3. **Exam Creation**
|
||||
- `/facilitator/api/v1/exams`
|
||||
- Creates new exam sessions
|
||||
- Handles session initialization
|
||||
|
||||
### 5. Error Handling
|
||||
|
||||
#### User Feedback
|
||||
- Displays error messages
|
||||
- Shows loading states
|
||||
- Provides network status
|
||||
- Handles API failures
|
||||
|
||||
#### Recovery Mechanisms
|
||||
- Auto-dismissing alerts
|
||||
- Network status monitoring
|
||||
- Session state recovery
|
||||
- Graceful error handling
|
||||
|
||||
### 6. Security Features
|
||||
|
||||
#### Access Control
|
||||
- Validates exam sessions
|
||||
- Checks user permissions
|
||||
- Manages secure redirects
|
||||
- Handles session timeouts
|
||||
|
||||
#### Data Protection
|
||||
- Secure API communication
|
||||
- Safe data storage
|
||||
- Protected user information
|
||||
- Secure state management
|
||||
|
||||
### 7. Performance Optimization
|
||||
|
||||
#### Loading Strategy
|
||||
- Lazy loading of components
|
||||
- Preloading of essential data
|
||||
- Efficient state updates
|
||||
- Optimized resource loading
|
||||
|
||||
#### UI Responsiveness
|
||||
- Smooth transitions
|
||||
- Non-blocking operations
|
||||
- Efficient DOM updates
|
||||
- Responsive design
|
||||
|
||||
### 8. Browser Compatibility
|
||||
|
||||
#### Cross-browser Support
|
||||
- Modern browser features
|
||||
- Fallback mechanisms
|
||||
- Consistent rendering
|
||||
- Progressive enhancement
|
||||
|
||||
#### Mobile Support
|
||||
- Responsive design
|
||||
- Touch-friendly interface
|
||||
- Mobile-optimized layout
|
||||
- Adaptive UI elements
|
||||
|
||||
### 9. User Experience
|
||||
|
||||
#### Feedback Mechanisms
|
||||
- Loading indicators
|
||||
- Progress updates
|
||||
- Status messages
|
||||
- Error notifications
|
||||
|
||||
#### Navigation
|
||||
- Clear call-to-actions
|
||||
- Intuitive flow
|
||||
- Easy access to features
|
||||
- Consistent navigation
|
||||
|
||||
### 10. Maintenance and Updates
|
||||
|
||||
#### Code Organization
|
||||
- Modular structure
|
||||
- Clear separation of concerns
|
||||
- Maintainable components
|
||||
- Extensible design
|
||||
|
||||
#### Future Enhancements
|
||||
- Feature addition points
|
||||
- Integration capabilities
|
||||
- Scalability considerations
|
||||
- Update mechanisms
|
||||
|
||||
## Component Interactions
|
||||
|
||||
### 1. Initial Page Load
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Check for existing exam
|
||||
await checkCurrentExamStatus();
|
||||
|
||||
// Preload labs data
|
||||
await fetchLabs(false);
|
||||
|
||||
// Initialize UI elements
|
||||
initializeUI();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Exam Selection Flow
|
||||
```javascript
|
||||
// Start Exam Button Click
|
||||
document.getElementById('startExamBtn').addEventListener('click', async () => {
|
||||
// Check for active exam
|
||||
const currentExam = await checkCurrentExamStatus();
|
||||
|
||||
if (currentExam) {
|
||||
showActiveExamWarningModal(currentExam);
|
||||
} else {
|
||||
showExamSelectionModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Exam Category Selection
|
||||
document.getElementById('examCategory').addEventListener('change', async (e) => {
|
||||
const category = e.target.value;
|
||||
await loadExamOptions(category);
|
||||
updateExamDescription();
|
||||
});
|
||||
|
||||
// Start Selected Exam
|
||||
document.getElementById('startSelectedExam').addEventListener('click', async () => {
|
||||
const examId = document.getElementById('examName').value;
|
||||
await startSelectedExam(examId);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Loading State Management
|
||||
```javascript
|
||||
// Show Loading Overlay
|
||||
function showLoadingOverlay(message) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
document.getElementById('loadingMessage').textContent = message;
|
||||
}
|
||||
|
||||
// Update Progress
|
||||
function updateProgress(progress) {
|
||||
document.getElementById('progressBar').style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
// Hide Loading Overlay
|
||||
function hideLoadingOverlay() {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
}
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### 1. Exam Status Check
|
||||
```javascript
|
||||
async function checkCurrentExamStatus() {
|
||||
try {
|
||||
const response = await fetch('/facilitator/api/v1/exams/current');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error checking exam status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Labs Data Fetching
|
||||
```javascript
|
||||
async function fetchLabs(showLoader = true) {
|
||||
try {
|
||||
if (showLoader) showLoadingOverlay('Loading available labs...');
|
||||
|
||||
const response = await fetch('/facilitator/api/v1/assements/');
|
||||
const data = await response.json();
|
||||
|
||||
// Update exam options
|
||||
updateExamOptions(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching labs:', error);
|
||||
showError('Failed to load available labs');
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoader) hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Exam Session Creation
|
||||
```javascript
|
||||
async function startSelectedExam(examId) {
|
||||
try {
|
||||
showLoadingOverlay('Creating exam session...');
|
||||
|
||||
const response = await fetch('/facilitator/api/v1/exams', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ examId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create exam session');
|
||||
|
||||
const data = await response.json();
|
||||
await pollExamStatus(data.id);
|
||||
|
||||
// Redirect to exam page
|
||||
window.location.href = `/exam.html?id=${data.id}`;
|
||||
} catch (error) {
|
||||
console.error('Error starting exam:', error);
|
||||
showError('Failed to start exam session');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
### 1. Modal Management
|
||||
```javascript
|
||||
// Show Exam Selection Modal
|
||||
function showExamSelectionModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('examSelectionModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Show Active Exam Warning
|
||||
function showActiveExamWarningModal(exam) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('activeExamWarningModal'));
|
||||
modal.show();
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('examInfo').textContent =
|
||||
`You have an active ${exam.type} exam session.`;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Form Validation
|
||||
```javascript
|
||||
// Validate Exam Selection
|
||||
function validateExamSelection() {
|
||||
const category = document.getElementById('examCategory').value;
|
||||
const exam = document.getElementById('examName').value;
|
||||
|
||||
const startButton = document.getElementById('startSelectedExam');
|
||||
startButton.disabled = !category || !exam;
|
||||
}
|
||||
|
||||
// Update Exam Description
|
||||
function updateExamDescription() {
|
||||
const exam = document.getElementById('examName').value;
|
||||
const description = document.getElementById('examDescription');
|
||||
|
||||
if (exam) {
|
||||
const examData = getExamData(exam);
|
||||
description.textContent = examData.description;
|
||||
} else {
|
||||
description.textContent = 'Select an exam to see its description.';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### 1. Local Storage
|
||||
```javascript
|
||||
// Save Current Exam
|
||||
function saveCurrentExam(examData) {
|
||||
localStorage.setItem('currentExamData', JSON.stringify(examData));
|
||||
localStorage.setItem('currentExamId', examData.id);
|
||||
}
|
||||
|
||||
// Get Current Exam
|
||||
function getCurrentExam() {
|
||||
const examData = localStorage.getItem('currentExamData');
|
||||
return examData ? JSON.parse(examData) : null;
|
||||
}
|
||||
|
||||
// Clear Current Exam
|
||||
function clearCurrentExam() {
|
||||
localStorage.removeItem('currentExamData');
|
||||
localStorage.removeItem('currentExamId');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. UI State
|
||||
```javascript
|
||||
// Update Results Button Visibility
|
||||
function updateResultsButtonVisibility() {
|
||||
const container = document.querySelector('.view-results-btn-container');
|
||||
const currentExam = getCurrentExam();
|
||||
|
||||
if (currentExam && currentExam.status === 'EVALUATED') {
|
||||
container.style.display = 'block';
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update Loading State
|
||||
function updateLoadingState(isLoading, message = '') {
|
||||
const loader = document.getElementById('pageLoader');
|
||||
const loaderMessage = document.getElementById('loaderMessage');
|
||||
|
||||
if (isLoading) {
|
||||
loader.style.display = 'flex';
|
||||
if (message) loaderMessage.textContent = message;
|
||||
} else {
|
||||
loader.style.display = 'none';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. API Error Handling
|
||||
```javascript
|
||||
// Show Error Message
|
||||
function showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||||
errorDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.querySelector('.container').prepend(errorDiv);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Handle API Errors
|
||||
function handleApiError(error, context) {
|
||||
console.error(`Error in ${context}:`, error);
|
||||
|
||||
let message = 'An unexpected error occurred.';
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 404:
|
||||
message = 'Resource not found.';
|
||||
break;
|
||||
case 403:
|
||||
message = 'Access denied.';
|
||||
break;
|
||||
case 500:
|
||||
message = 'Server error. Please try again later.';
|
||||
break;
|
||||
default:
|
||||
message = error.response.data.message || message;
|
||||
}
|
||||
}
|
||||
|
||||
showError(message);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Network Error Handling
|
||||
```javascript
|
||||
// Check Network Status
|
||||
function checkNetworkStatus() {
|
||||
if (!navigator.onLine) {
|
||||
showError('No internet connection. Please check your network.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Network Status Event Listeners
|
||||
window.addEventListener('online', () => {
|
||||
showError('Connection restored. You can continue.');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
showError('No internet connection. Please check your network.');
|
||||
});
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `index.html` file serves as the entry point for the CK-X Simulator, providing a user-friendly interface for exam selection and management. It implements robust error handling, state management, and API integration to ensure a smooth user experience. The modular design allows for easy maintenance and future enhancements.
|
||||
|
||||
Key aspects of the implementation include:
|
||||
- Clean separation of concerns between UI and business logic
|
||||
- Comprehensive error handling and user feedback
|
||||
- Efficient state management using localStorage
|
||||
- Responsive design for various screen sizes
|
||||
- Clear user flow for exam selection and management
|
||||
571
docs/webapp/results-functionality.md
Normal file
571
docs/webapp/results-functionality.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# CK-X Simulator: results.html Functionality Documentation
|
||||
|
||||
This document provides a detailed technical overview of the `results.html` file, focusing on its structure, interactions, and API calls.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [HTML Structure](#html-structure)
|
||||
2. [Functionality Overview](#functionality-overview)
|
||||
3. [Component Interactions](#component-interactions)
|
||||
4. [API Integration](#api-integration)
|
||||
5. [Event Handlers](#event-handlers)
|
||||
6. [State Management](#state-management)
|
||||
7. [Error Handling](#error-handling)
|
||||
|
||||
## HTML Structure
|
||||
|
||||
The `results.html` file serves as the results interface for the CK-X Simulator. Here's a detailed breakdown of its structure:
|
||||
|
||||
### 1. Head Section
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exam Results - CK-X Simulator</title>
|
||||
<!-- External Dependencies -->
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="stylesheet" href="/css/results.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
</head>
|
||||
```
|
||||
|
||||
### 2. Body Components
|
||||
|
||||
#### Action Buttons
|
||||
```html
|
||||
<div class="results-actions">
|
||||
<button id="dashboardBtn" class="btn btn-secondary">
|
||||
<i class="fas fa-home me-2"></i>Dashboard
|
||||
</button>
|
||||
<button id="currentExamBtn" class="btn btn-info">
|
||||
<i class="fas fa-tasks me-2"></i>Current Exam
|
||||
</button>
|
||||
<button id="viewAnswersBtn" class="btn btn-success">
|
||||
<i class="fas fa-check-square me-2"></i>View Answers
|
||||
</button>
|
||||
<button id="reEvaluateBtn" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt me-2"></i>Re-evaluate Exam
|
||||
</button>
|
||||
<button id="terminateBtn" class="btn btn-danger">
|
||||
<i class="fas fa-power-off me-2"></i>Terminate Session
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Main navigation and action controls
|
||||
- **Features**: Dashboard access, exam management, answer review, re-evaluation, session termination
|
||||
|
||||
#### Loading State
|
||||
```html
|
||||
<div id="pageLoader" class="page-loader">
|
||||
<div class="spinner"></div>
|
||||
<p id="loaderMessage">Loading exam results...</p>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays during results loading
|
||||
- **State Management**: Controlled by `showLoader()` and `hideLoader()` functions
|
||||
|
||||
#### Error Message
|
||||
```html
|
||||
<div id="errorMessage" class="error-message" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p id="errorText">An error occurred while loading the results.</p>
|
||||
<button id="retryButton" class="button">Retry</button>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays error states with retry option
|
||||
- **Error Handling**: Controlled by `showError()` and `hideError()` functions
|
||||
|
||||
#### Results Content
|
||||
```html
|
||||
<div id="resultsContent" class="results-content" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Exam Results</h2>
|
||||
<div class="exam-info">
|
||||
<p id="examId">Exam ID: Loading...</p>
|
||||
<p id="completedAt">Completed: Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results content -->
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Displays exam results and statistics
|
||||
- **Dynamic Content**: Updated based on API responses
|
||||
|
||||
#### Terminate Modal
|
||||
```html
|
||||
<div id="terminateModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<!-- Modal content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- **Purpose**: Confirms session termination
|
||||
- **Safety Features**: Clear warning messages and confirmation required
|
||||
|
||||
## Functionality Overview
|
||||
|
||||
The `results.html` file provides a comprehensive results interface for the CK-X Simulator. Here's a detailed breakdown of its functionality:
|
||||
|
||||
### 1. Results Display Components
|
||||
|
||||
#### Score Summary
|
||||
1. **Score Display**
|
||||
- Total score calculation
|
||||
- Maximum possible score
|
||||
- Percentage calculation
|
||||
- Visual score representation
|
||||
|
||||
2. **Rank Badge**
|
||||
- Performance level indicator
|
||||
- Visual status representation
|
||||
- Dynamic color coding
|
||||
|
||||
#### Question Breakdown
|
||||
1. **Question List**
|
||||
- Individual question scores
|
||||
- Correct/incorrect indicators
|
||||
- Detailed feedback
|
||||
- Answer review options
|
||||
|
||||
2. **Performance Metrics**
|
||||
- Time taken per question
|
||||
- Difficulty level indicators
|
||||
- Success rate analysis
|
||||
|
||||
### 2. Action Management
|
||||
|
||||
#### Navigation Controls
|
||||
1. **Dashboard Access**
|
||||
- Return to main dashboard
|
||||
- Session history access
|
||||
- Performance overview
|
||||
|
||||
2. **Exam Management**
|
||||
- Current exam access
|
||||
- Session continuation
|
||||
- Environment restoration
|
||||
|
||||
#### Review Options
|
||||
1. **Answer Review**
|
||||
- Detailed answer display
|
||||
- Correct solution comparison
|
||||
- Learning resources
|
||||
|
||||
2. **Re-evaluation**
|
||||
- Score recalculation
|
||||
- Performance reassessment
|
||||
- Result updates
|
||||
|
||||
### 3. State Management
|
||||
|
||||
#### Results State
|
||||
- Score tracking
|
||||
- Question status
|
||||
- Performance metrics
|
||||
- Session information
|
||||
|
||||
#### UI State
|
||||
- Loading states
|
||||
- Error states
|
||||
- Modal visibility
|
||||
- Content display
|
||||
|
||||
### 4. API Integration
|
||||
|
||||
#### Endpoints
|
||||
1. **Results Data**
|
||||
- `/facilitator/api/v1/exams/{id}/results`
|
||||
- Score retrieval
|
||||
- Performance data
|
||||
|
||||
2. **Question Details**
|
||||
- `/facilitator/api/v1/exams/{id}/questions`
|
||||
- Answer review
|
||||
- Solution access
|
||||
|
||||
3. **Session Management**
|
||||
- `/facilitator/api/v1/exams/{id}/session`
|
||||
- Status updates
|
||||
- Environment control
|
||||
|
||||
## Component Interactions
|
||||
|
||||
### 1. Initial Results Load Sequence
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Browser
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
User->>Browser: Access results.html
|
||||
Browser->>UI: DOMContentLoaded
|
||||
UI->>UI: Show Loader
|
||||
UI->>API: Get examId from URL
|
||||
API-->>UI: Return examId
|
||||
UI->>API: fetchExamResults(examId)
|
||||
API-->>UI: Return results data
|
||||
UI->>State: processResults()
|
||||
State-->>UI: Processed data
|
||||
UI->>UI: renderResults()
|
||||
UI->>UI: Hide Loader
|
||||
UI-->>User: Display results
|
||||
```
|
||||
|
||||
### 2. Results Display Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
UI->>API: requestDetailedResults()
|
||||
API-->>UI: Return detailed data
|
||||
UI->>State: updateResultsState()
|
||||
State-->>UI: Updated state
|
||||
UI->>UI: updateScoreDisplay()
|
||||
UI->>UI: updateQuestionList()
|
||||
UI->>UI: updatePerformanceMetrics()
|
||||
UI-->>User: Updated display
|
||||
```
|
||||
|
||||
### 3. Answer Review Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
User->>UI: Click "View Answers"
|
||||
UI->>API: requestQuestionDetails()
|
||||
API-->>UI: Return question data
|
||||
UI->>State: saveCurrentQuestion()
|
||||
UI->>UI: displayAnswerDetails()
|
||||
UI->>UI: showSolution()
|
||||
UI-->>User: Display answer review
|
||||
```
|
||||
|
||||
### 4. Re-evaluation Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
User->>UI: Click "Re-evaluate"
|
||||
UI->>UI: showConfirmation()
|
||||
User->>UI: Confirm re-evaluation
|
||||
UI->>API: requestReEvaluation()
|
||||
API-->>UI: Return new results
|
||||
UI->>State: updateResults()
|
||||
UI->>UI: refreshDisplay()
|
||||
UI-->>User: Updated results
|
||||
```
|
||||
|
||||
### 5. Session Termination Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
User->>UI: Click "Terminate"
|
||||
UI->>UI: showTerminateModal()
|
||||
User->>UI: Confirm termination
|
||||
UI->>API: requestTermination()
|
||||
API-->>UI: Confirmation
|
||||
UI->>State: clearSessionData()
|
||||
UI->>UI: redirectToDashboard()
|
||||
UI-->>User: Redirected
|
||||
```
|
||||
|
||||
### 6. Error Recovery Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant State
|
||||
|
||||
Note over UI: Error detected
|
||||
UI->>UI: showError(message)
|
||||
User->>UI: Click "Retry"
|
||||
UI->>API: retryRequest()
|
||||
alt Success
|
||||
API-->>UI: Return data
|
||||
UI->>State: updateState()
|
||||
UI-->>User: Display results
|
||||
else Failure
|
||||
API-->>UI: Error response
|
||||
UI->>UI: showError()
|
||||
end
|
||||
```
|
||||
|
||||
### 7. State Management Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant State
|
||||
participant localStorage
|
||||
|
||||
Note over UI: Save state
|
||||
UI->>State: saveResultsState()
|
||||
State->>localStorage: setItem('resultsState')
|
||||
Note over UI: Page reload
|
||||
UI->>localStorage: getItem('resultsState')
|
||||
localStorage-->>UI: State data
|
||||
UI->>State: restoreState()
|
||||
State->>UI: Update display
|
||||
UI-->>User: State restored
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### 1. Results Fetching
|
||||
```javascript
|
||||
async function fetchExamResults(examId) {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/results`);
|
||||
if (!response.ok) throw new Error('Failed to fetch results');
|
||||
|
||||
const data = await response.json();
|
||||
processResults(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching results:', error);
|
||||
showError('Failed to load exam results');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Question Details
|
||||
```javascript
|
||||
async function fetchQuestionDetails(questionId) {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/questions/${questionId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch question details');
|
||||
|
||||
const data = await response.json();
|
||||
displayQuestionDetails(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching question details:', error);
|
||||
showError('Failed to load question details');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Session Management
|
||||
```javascript
|
||||
async function terminateSession() {
|
||||
try {
|
||||
const response = await fetch(`/facilitator/api/v1/exams/${examId}/terminate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to terminate session');
|
||||
|
||||
clearSessionData();
|
||||
redirectToDashboard();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error terminating session:', error);
|
||||
showError('Failed to terminate session');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
### 1. Button Actions
|
||||
```javascript
|
||||
// Dashboard Navigation
|
||||
document.getElementById('dashboardBtn').addEventListener('click', () => {
|
||||
window.location.href = '/dashboard';
|
||||
});
|
||||
|
||||
// Current Exam Access
|
||||
document.getElementById('currentExamBtn').addEventListener('click', () => {
|
||||
window.location.href = `/exam?id=${examId}`;
|
||||
});
|
||||
|
||||
// Answer Review
|
||||
document.getElementById('viewAnswersBtn').addEventListener('click', async () => {
|
||||
await loadQuestionDetails();
|
||||
showAnswerReview();
|
||||
});
|
||||
|
||||
// Re-evaluation
|
||||
document.getElementById('reEvaluateBtn').addEventListener('click', async () => {
|
||||
if (confirm('Are you sure you want to re-evaluate the exam?')) {
|
||||
await reEvaluateExam();
|
||||
}
|
||||
});
|
||||
|
||||
// Session Termination
|
||||
document.getElementById('terminateBtn').addEventListener('click', () => {
|
||||
showTerminateModal();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Modal Management
|
||||
```javascript
|
||||
// Show Terminate Modal
|
||||
function showTerminateModal() {
|
||||
document.getElementById('terminateModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// Hide Terminate Modal
|
||||
function hideTerminateModal() {
|
||||
document.getElementById('terminateModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle Modal Actions
|
||||
document.getElementById('confirmTerminateBtn').addEventListener('click', async () => {
|
||||
await terminateSession();
|
||||
hideTerminateModal();
|
||||
});
|
||||
|
||||
document.getElementById('cancelTerminateBtn').addEventListener('click', () => {
|
||||
hideTerminateModal();
|
||||
});
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### 1. Results State
|
||||
```javascript
|
||||
// Save Results State
|
||||
function saveResultsState() {
|
||||
const state = {
|
||||
examId: currentExamId,
|
||||
results: currentResults,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
localStorage.setItem('resultsState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
// Load Results State
|
||||
function loadResultsState() {
|
||||
const state = localStorage.getItem('resultsState');
|
||||
if (state) {
|
||||
const parsedState = JSON.parse(state);
|
||||
restoreResultsState(parsedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Results State
|
||||
function clearResultsState() {
|
||||
localStorage.removeItem('resultsState');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. UI State
|
||||
```javascript
|
||||
// Update UI State
|
||||
function updateUIState() {
|
||||
updateScoreDisplay();
|
||||
updateQuestionList();
|
||||
updatePerformanceMetrics();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
// Save UI Preferences
|
||||
function saveUIPreferences() {
|
||||
const preferences = {
|
||||
showDetails: showDetailedResults,
|
||||
sortOrder: currentSortOrder,
|
||||
filterSettings: currentFilters
|
||||
};
|
||||
|
||||
localStorage.setItem('uiPreferences', JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
// Load UI Preferences
|
||||
function loadUIPreferences() {
|
||||
const preferences = localStorage.getItem('uiPreferences');
|
||||
if (preferences) {
|
||||
const parsedPreferences = JSON.parse(preferences);
|
||||
applyUIPreferences(parsedPreferences);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Error Display
|
||||
```javascript
|
||||
// Show Error Message
|
||||
function showError(message) {
|
||||
const errorElement = document.getElementById('errorMessage');
|
||||
const errorText = document.getElementById('errorText');
|
||||
|
||||
errorText.textContent = message;
|
||||
errorElement.style.display = 'block';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
hideError();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Hide Error Message
|
||||
function hideError() {
|
||||
document.getElementById('errorMessage').style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle Retry
|
||||
document.getElementById('retryButton').addEventListener('click', async () => {
|
||||
hideError();
|
||||
await loadResults();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. State Recovery
|
||||
```javascript
|
||||
// Attempt State Recovery
|
||||
async function attemptStateRecovery() {
|
||||
try {
|
||||
const state = loadResultsState();
|
||||
if (state && state.examId) {
|
||||
await restoreResultsState(state);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('State Recovery Error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Session Timeout
|
||||
function handleSessionTimeout() {
|
||||
showError('Session timeout. Attempting to recover...');
|
||||
attemptStateRecovery();
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `results.html` file provides a comprehensive results interface for the CK-X Simulator, implementing robust features for displaying exam results, managing sessions, and facilitating review and re-evaluation. The modular design ensures maintainability and extensibility while providing a clear and informative results experience.
|
||||
|
||||
Key aspects of the implementation include:
|
||||
- Detailed score and performance display
|
||||
- Comprehensive question breakdown
|
||||
- Flexible review options
|
||||
- Robust error handling
|
||||
- Secure session management
|
||||
- Responsive and intuitive interface
|
||||
57
facilitator/.dockerignore
Normal file
57
facilitator/.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
compose*.yaml
|
||||
.dockerignore
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Other unnecessary files
|
||||
*.gz
|
||||
*.zip
|
||||
*.tar
|
||||
*.rar
|
||||
tmp/
|
||||
temp/
|
||||
32
facilitator/.gitignore
vendored
Normal file
32
facilitator/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
48
facilitator/Dockerfile
Normal file
48
facilitator/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install app dependencies and SSH client for connecting to jumphost
|
||||
RUN apk add --no-cache openssh-client
|
||||
|
||||
# Install dependencies
|
||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Bundle app source
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
|
||||
# Create a non-root user and switch to it
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodeuser -u 1001 -G nodejs
|
||||
|
||||
# Setup SSH for passwordless authentication
|
||||
RUN mkdir -p /home/nodeuser/.ssh && \
|
||||
chmod 700 /home/nodeuser/.ssh && \
|
||||
echo "Host jumphost\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /home/nodeuser/.ssh/config && \
|
||||
chmod 600 /home/nodeuser/.ssh/config && \
|
||||
chown -R nodeuser:nodejs /home/nodeuser/.ssh
|
||||
|
||||
# Change ownership of the app files to the non-root user
|
||||
RUN chown -R nodeuser:nodejs /usr/src/app
|
||||
|
||||
# Expose the service port
|
||||
EXPOSE 3000
|
||||
|
||||
# Copy entrypoint.sh to /usr/src/app
|
||||
COPY entrypoint.sh /home/nodeuser/entrypoint.sh
|
||||
|
||||
# Make entrypoint.sh executable
|
||||
RUN chmod +x /home/nodeuser/entrypoint.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodeuser
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "/home/nodeuser/entrypoint.sh"]
|
||||
125
facilitator/README.md
Normal file
125
facilitator/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Facilitator Service
|
||||
|
||||
A Node.js service that provides SSH jumphost functionality and exam management capabilities via a REST API.
|
||||
|
||||
## Features
|
||||
|
||||
- Execute commands on a remote SSH jumphost
|
||||
- Support for both password and passwordless SSH authentication
|
||||
- Exam management API endpoints (some implemented, others are placeholders)
|
||||
- Secure and modular architecture
|
||||
- Comprehensive logging
|
||||
- Containerization with Docker
|
||||
- Integration with Docker Compose for multi-service deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- SSH access to the target jumphost
|
||||
|
||||
## Installation
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Clone the repository
|
||||
2. Navigate to the project directory
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Create a `.env` file based on the provided example:
|
||||
|
||||
```
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# SSH Jumphost Configuration
|
||||
SSH_HOST=<your-ssh-host>
|
||||
SSH_PORT=22
|
||||
SSH_USERNAME=<your-ssh-username>
|
||||
SSH_PASSWORD=<your-ssh-password>
|
||||
# Alternatively, use SSH key authentication
|
||||
# SSH_PRIVATE_KEY_PATH=/path/to/private/key
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
5. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
#### Standalone
|
||||
|
||||
1. Build the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t facilitator-service .
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
|
||||
```bash
|
||||
docker run -p 3001:3000 --env-file .env facilitator-service
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
The facilitator service is integrated into the main Docker Compose configuration at the project root. To run it with the full stack:
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start the facilitator service along with all other services, including the jumphost that the facilitator connects to for SSH command execution. In Docker Compose, the service is configured to use passwordless SSH authentication with the jumphost.
|
||||
|
||||
The service is accessible at:
|
||||
- URL: http://localhost:3001
|
||||
- Internal network name: facilitator
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### SSH Command Execution
|
||||
|
||||
- **POST /api/v1/execute**
|
||||
- Execute a command on the SSH jumphost
|
||||
- Request body: `{ "command": "your-command-here" }`
|
||||
- Response: `{ "exitCode": 0, "stdout": "output", "stderr": "errors" }`
|
||||
|
||||
### Exam Management
|
||||
|
||||
- **GET /api/v1/exams/**
|
||||
- Get a list of all exams
|
||||
- Returns an array of exam objects containing id, name, category, description, etc.
|
||||
|
||||
- **POST /api/v1/exams/**
|
||||
- Create a new exam
|
||||
- Returns exam ID and type (placeholder)
|
||||
|
||||
- **GET /api/v1/exams/:examId/assets**
|
||||
- Get assets for a specific exam
|
||||
- Returns empty object (placeholder)
|
||||
|
||||
- **GET /api/v1/exams/:examId/questions/**
|
||||
- Get questions for a specific exam
|
||||
- Returns empty object (placeholder)
|
||||
|
||||
- **POST /api/v1/exams/:examId/evaluate/**
|
||||
- Evaluate an exam
|
||||
- Returns empty object (placeholder)
|
||||
|
||||
- **POST /api/v1/exams/:examId/end**
|
||||
- End an exam
|
||||
- Returns empty object (placeholder)
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
564
facilitator/assets/exams/ckad/001/answers.md
Normal file
564
facilitator/assets/exams/ckad/001/answers.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# CKAD Practice Exam - Answers
|
||||
|
||||
## Question 1: Create a deployment called nginx-deployment in the namespace dev with 3 replicas and image nginx:latest
|
||||
|
||||
```bash
|
||||
# Create the namespace if it doesn't exist
|
||||
kubectl create namespace dev
|
||||
|
||||
# Create the deployment with 3 replicas
|
||||
kubectl create deployment nginx-deployment -n dev --image=nginx:latest --replicas=3
|
||||
```
|
||||
|
||||
## Question 2: Create a PersistentVolume named 'pv-storage' with 1Gi capacity, access mode ReadWriteOnce, hostPath type at /mnt/data, and reclaim policy Retain
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: pv-storage
|
||||
spec:
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /mnt/data
|
||||
```
|
||||
|
||||
Save this as `pv-storage.yaml` and apply:
|
||||
|
||||
```bash
|
||||
kubectl apply -f pv-storage.yaml
|
||||
```
|
||||
|
||||
## Question 3: Create a StorageClass named 'fast-storage' with provisioner 'kubernetes.io/no-provisioner' and volumeBindingMode 'WaitForFirstConsumer'
|
||||
|
||||
```yaml
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: fast-storage
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
```
|
||||
|
||||
Save this as `storage-class.yaml` and apply:
|
||||
|
||||
```bash
|
||||
kubectl apply -f storage-class.yaml
|
||||
```
|
||||
|
||||
## Question 4: Create a PersistentVolumeClaim named 'pvc-app' that requests 500Mi of storage with ReadWriteOnce access mode and uses the 'fast-storage' StorageClass
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: pvc-app
|
||||
namespace: storage-test
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 500Mi
|
||||
storageClassName: fast-storage
|
||||
```
|
||||
|
||||
Save this as `pvc.yaml` and apply:
|
||||
|
||||
```bash
|
||||
kubectl apply -f pvc.yaml -n storage-test
|
||||
```
|
||||
|
||||
## Question 5: The deployment 'broken-app' in namespace 'troubleshooting' is failing to start. Identify and fix the issue
|
||||
|
||||
Troubleshooting steps:
|
||||
```bash
|
||||
# Check the pods in the troubleshooting namespace
|
||||
kubectl get pods -n troubleshooting
|
||||
|
||||
# Check the pod details for errors
|
||||
kubectl describe pod -l app=broken-app -n troubleshooting
|
||||
|
||||
# Check logs of the failing pod
|
||||
kubectl logs <pod-name> -n troubleshooting
|
||||
```
|
||||
|
||||
Potential fixes:
|
||||
1. If the image is incorrect:
|
||||
```bash
|
||||
kubectl set image deployment/broken-app container-name=correct-image:tag -n troubleshooting
|
||||
```
|
||||
2. If environment variables are missing:
|
||||
```bash
|
||||
kubectl edit deployment broken-app -n troubleshooting
|
||||
```
|
||||
3. If resource limits are too low:
|
||||
```bash
|
||||
kubectl patch deployment broken-app -n troubleshooting -p '{"spec":{"template":{"spec":{"containers":[{"name":"container-name","resources":{"limits":{"memory":"512Mi"}}}]}}}}'
|
||||
```
|
||||
|
||||
## Question 6: The kubelet on node 'worker-1' is not functioning properly. Diagnose and fix the issue
|
||||
|
||||
Troubleshooting steps:
|
||||
```bash
|
||||
# Check node status
|
||||
kubectl get nodes
|
||||
|
||||
# Describe the node for more information
|
||||
kubectl describe node worker-1
|
||||
|
||||
# SSH into the worker node
|
||||
ssh worker-1
|
||||
|
||||
# Check kubelet status
|
||||
systemctl status kubelet
|
||||
|
||||
# Check kubelet logs
|
||||
journalctl -u kubelet -n 100
|
||||
|
||||
# Restart kubelet if needed
|
||||
systemctl restart kubelet
|
||||
|
||||
# Check kubelet configuration
|
||||
cat /var/lib/kubelet/config.yaml
|
||||
```
|
||||
|
||||
Common kubelet issues:
|
||||
1. Service not running: `systemctl start kubelet`
|
||||
2. Configuration errors: Edit `/var/lib/kubelet/config.yaml`
|
||||
3. Certificate issues: Renew certificates if needed
|
||||
4. Disk space issues: `df -h` to check and clean up if needed
|
||||
|
||||
## Question 7: Service 'web-service' in namespace 'troubleshooting' is not routing traffic to pods properly. Identify and fix the issue
|
||||
|
||||
Troubleshooting steps:
|
||||
```bash
|
||||
# Check the service
|
||||
kubectl get svc web-service -n troubleshooting
|
||||
|
||||
# Describe the service to check selector labels
|
||||
kubectl describe svc web-service -n troubleshooting
|
||||
|
||||
# Check if there are pods matching the selector
|
||||
kubectl get pods -l <service-selector-label> -n troubleshooting
|
||||
```
|
||||
|
||||
Common fixes:
|
||||
1. Fix service selector to match pod labels:
|
||||
```bash
|
||||
kubectl edit svc web-service -n troubleshooting
|
||||
```
|
||||
2. Fix pod labels to match service selector:
|
||||
```bash
|
||||
kubectl label pods <pod-name> key=value -n troubleshooting
|
||||
```
|
||||
3. Fix service port mapping:
|
||||
```bash
|
||||
kubectl edit svc web-service -n troubleshooting
|
||||
```
|
||||
|
||||
## Question 8: Pod 'logging-pod' in namespace 'troubleshooting' is experiencing high CPU usage. Identify the container causing the issue and take appropriate action to limit its CPU usage
|
||||
|
||||
```bash
|
||||
# Check current resource usage
|
||||
kubectl top pod logging-pod -n troubleshooting
|
||||
kubectl top pod logging-pod -n troubleshooting --containers
|
||||
|
||||
# Add CPU limits to the container
|
||||
kubectl patch pod logging-pod -n troubleshooting -p '{"spec":{"containers":[{"name":"<container-name>","resources":{"limits":{"cpu":"200m"}}}]}}'
|
||||
```
|
||||
|
||||
Or edit the deployment if the pod is managed by one:
|
||||
```bash
|
||||
kubectl edit deployment <deployment-name> -n troubleshooting
|
||||
```
|
||||
|
||||
Add the following to the container spec:
|
||||
```yaml
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
requests:
|
||||
cpu: 100m
|
||||
```
|
||||
|
||||
## Question 9: Create a ConfigMap named 'app-config' in namespace 'workloads' containing the following key-value pairs: APP_ENV=production, LOG_LEVEL=info. Then create a Pod named 'config-pod' using 'nginx' image that mounts these configurations as environment variables
|
||||
|
||||
```bash
|
||||
# Create the ConfigMap
|
||||
kubectl create configmap app-config -n workloads --from-literal=APP_ENV=production --from-literal=LOG_LEVEL=info
|
||||
```
|
||||
|
||||
Create the Pod with ConfigMap environment variables:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: config-pod
|
||||
namespace: workloads
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
env:
|
||||
- name: APP_ENV
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: app-config
|
||||
key: APP_ENV
|
||||
- name: LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: app-config
|
||||
key: LOG_LEVEL
|
||||
```
|
||||
|
||||
Save as `config-pod.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f config-pod.yaml
|
||||
```
|
||||
|
||||
## Question 10: Create a Secret named 'db-credentials' in namespace 'workloads' containing username=admin and password=securepass. Then create a Pod named 'secure-pod' using 'mysql:5.7' image with these credentials set as environment variables DB_USER and DB_PASSWORD
|
||||
|
||||
```bash
|
||||
# Create the Secret
|
||||
kubectl create secret generic db-credentials -n workloads --from-literal=username=admin --from-literal=password=securepass
|
||||
```
|
||||
|
||||
Create the Pod with Secret environment variables:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: secure-pod
|
||||
namespace: workloads
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:5.7
|
||||
env:
|
||||
- name: DB_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: db-credentials
|
||||
key: username
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: db-credentials
|
||||
key: password
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: db-credentials
|
||||
key: password
|
||||
```
|
||||
|
||||
Save as `secure-pod.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f secure-pod.yaml
|
||||
```
|
||||
|
||||
## Question 11: Create a Horizontal Pod Autoscaler for the deployment 'web-app' in namespace 'workloads' that scales between 2 and 6 replicas based on 70% CPU utilization
|
||||
|
||||
```bash
|
||||
# Create the HPA
|
||||
kubectl autoscale deployment web-app -n workloads --min=2 --max=6 --cpu-percent=70
|
||||
```
|
||||
|
||||
Or using YAML:
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: web-app
|
||||
namespace: workloads
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: web-app
|
||||
minReplicas: 2
|
||||
maxReplicas: 6
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
Save as `hpa.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f hpa.yaml
|
||||
```
|
||||
|
||||
## Question 12: Create a Pod named 'health-pod' in namespace 'workloads' using 'nginx' image with a liveness probe that checks the path /healthz on port 80 every 15 seconds, and a readiness probe that checks port 80 every 10 seconds
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: health-pod
|
||||
namespace: workloads
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 80
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
Save as `health-pod.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f health-pod.yaml
|
||||
```
|
||||
|
||||
## Question 13: Create a ClusterRole named 'pod-reader' that allows getting, watching, and listing pods. Then create a ClusterRoleBinding named 'read-pods' that grants this role to the user 'jane' in the namespace 'cluster-admin'
|
||||
|
||||
```bash
|
||||
# Create the ClusterRole
|
||||
kubectl create clusterrole pod-reader --verb=get,watch,list --resource=pods
|
||||
```
|
||||
|
||||
Or using YAML:
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: pod-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "watch", "list"]
|
||||
```
|
||||
|
||||
Create the ClusterRoleBinding:
|
||||
```bash
|
||||
kubectl create clusterrolebinding read-pods --clusterrole=pod-reader --user=jane --namespace=cluster-admin
|
||||
```
|
||||
|
||||
Or using YAML:
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: read-pods
|
||||
subjects:
|
||||
- kind: User
|
||||
name: jane
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: pod-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
Save YAML files and apply them:
|
||||
```bash
|
||||
kubectl apply -f cluster-role.yaml
|
||||
kubectl apply -f cluster-role-binding.yaml
|
||||
```
|
||||
|
||||
## Question 14: Install Helm and use it to deploy the Prometheus monitoring stack in the 'monitoring' namespace
|
||||
|
||||
```bash
|
||||
# Install Helm
|
||||
curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
|
||||
|
||||
# Create the namespace
|
||||
kubectl create namespace monitoring
|
||||
|
||||
# Add Prometheus repo
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm repo update
|
||||
|
||||
# Install Prometheus stack
|
||||
helm install prometheus prometheus-community/kube-prometheus-stack --namespace monitoring
|
||||
```
|
||||
|
||||
## Question 15: Create a CRD (CustomResourceDefinition) for a new resource type 'Backup' in API group 'data.example.com' with version 'v1alpha1' that includes fields 'spec.source' and 'spec.destination'
|
||||
|
||||
```yaml
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: backups.data.example.com
|
||||
spec:
|
||||
group: data.example.com
|
||||
names:
|
||||
kind: Backup
|
||||
listKind: BackupList
|
||||
plural: backups
|
||||
singular: backup
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1alpha1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
destination:
|
||||
type: string
|
||||
required: ["source", "destination"]
|
||||
```
|
||||
|
||||
Save as `backup-crd.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f backup-crd.yaml
|
||||
```
|
||||
|
||||
## Question 16: Create a NetworkPolicy named 'allow-traffic' in namespace 'networking' that allows traffic to pods with label 'app=web' only from pods with label 'tier=frontend' on port 80
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-traffic
|
||||
namespace: networking
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: web
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
tier: frontend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
```
|
||||
|
||||
Save as `network-policy.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f network-policy.yaml
|
||||
```
|
||||
|
||||
## Question 17: Create a ClusterIP service named 'internal-app' in namespace 'networking' that routes traffic to pods with label 'app=backend' on port 8080, exposing the service on port 80
|
||||
|
||||
```bash
|
||||
kubectl create service clusterip internal-app --tcp=80:8080 -n networking --selector=app=backend
|
||||
```
|
||||
|
||||
Or using YAML:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: internal-app
|
||||
namespace: networking
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
Save as `internal-service.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f internal-service.yaml
|
||||
```
|
||||
|
||||
## Question 18: Create a LoadBalancer service named 'public-web' in namespace 'networking' that exposes port 80 for the deployment 'web-frontend'
|
||||
|
||||
```bash
|
||||
kubectl expose deployment web-frontend --type=LoadBalancer --port=80 --name=public-web -n networking
|
||||
```
|
||||
|
||||
Or using YAML:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: public-web
|
||||
namespace: networking
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: web-frontend
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
Save as `loadbalancer-service.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f loadbalancer-service.yaml
|
||||
```
|
||||
|
||||
## Question 19: Create an Ingress resource named 'api-ingress' in namespace 'networking' that routes traffic from 'api.example.com' to the service 'api-service' on port 80
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: api-ingress
|
||||
namespace: networking
|
||||
spec:
|
||||
rules:
|
||||
- host: api.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
Save as `ingress.yaml` and apply:
|
||||
```bash
|
||||
kubectl apply -f ingress.yaml
|
||||
```
|
||||
|
||||
## Question 20: Configure CoreDNS to add a custom entry that resolves 'database.local' to the IP address 10.96.0.20
|
||||
|
||||
```bash
|
||||
# Edit the CoreDNS ConfigMap
|
||||
kubectl edit configmap coredns -n kube-system
|
||||
```
|
||||
|
||||
Add the following to the Corefile data:
|
||||
```
|
||||
hosts {
|
||||
10.96.0.20 database.local
|
||||
fallthrough
|
||||
}
|
||||
```
|
||||
|
||||
Restart CoreDNS pods:
|
||||
```bash
|
||||
kubectl delete pod -l k8s-app=kube-dns -n kube-system
|
||||
```
|
||||
|
||||
Or create a custom ConfigMap with hosts entries and mount it in the CoreDNS deployment.
|
||||
613
facilitator/assets/exams/ckad/001/assessment.json
Normal file
613
facilitator/assets/exams/ckad/001/assessment.json
Normal file
@@ -0,0 +1,613 @@
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "1",
|
||||
"namespace": "dev",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "In this task, you need to set up a basic web application deployment. \n\nCreate a deployment called `nginx-deployment` in the namespace `dev` with `3` replicas using the image `nginx:latest`. \n\nEnsure that the namespace exists before creating the deployment. The deployment should maintain exactly 3 pods running at all times, and all pods should be using the specified nginx image version.",
|
||||
"concepts": ["deployments", "namespaces", "replicas"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Namespace is created",
|
||||
"verificationScriptFile": "q1_s1_validate_namespace.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Deployment is created",
|
||||
"verificationScriptFile": "q1_s2_validate_deployment.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Deployment is is using correct image nginx",
|
||||
"verificationScriptFile": "q1_s3_validate_deployment_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"description": "Deployment has 3 replicas",
|
||||
"verificationScriptFile": "q1_s4_validate_deployment_replicas.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"namespace": "storage-test",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "For this storage-related task, you need to provision persistent storage resources. \n\nCreate a PersistentVolume named `pv-storage` with exactly `1Gi` capacity. Configure it with access mode `ReadWriteOnce` to allow read-write access by a single node. \n\nUse `hostPath` type pointing to the directory `/mnt/data` on the node. \n\nSet the reclaim policy to `Retain` so that the storage resource is not automatically deleted when the PV is released. \n\nThis PV will be used by applications requiring persistent storage in the cluster.",
|
||||
"concepts": ["persistent-volumes", "storage", "hostPath"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "PersistentVolume is created",
|
||||
"verificationScriptFile": "q2_s1_validate_pv_created.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "PersistentVolume has correct capacity",
|
||||
"verificationScriptFile": "q2_s2_validate_pv_capacity.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "PersistentVolume has correct access mode",
|
||||
"verificationScriptFile": "q2_s3_validate_pv_access_mode.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"description": "PersistentVolume has correct reclaim policy",
|
||||
"verificationScriptFile": "q2_s4_validate_pv_reclaim_policy.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"namespace": "storage-test",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "As part of optimizing storage resources in the cluster, create a StorageClass named `slow-storage` that will dynamically provision storage resources. \n\nUse the provisioner `kubernetes.io/no-provisioner` for this local storage class. \n\nSet the volumeBindingMode to `WaitForFirstConsumer` to delay volume binding until a pod using the PVC is created. \n\nThis storage class will be used for applications that require optimized local storage performance.",
|
||||
"concepts": ["storage-class", "provisioners", "volume-binding"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "StorageClass is created",
|
||||
"verificationScriptFile": "q3_s1_validate_storageclass_created.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "StorageClass has correct provisioner",
|
||||
"verificationScriptFile": "q3_s2_validate_storageclass_provisioner.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "StorageClass has correct volumeBindingMode",
|
||||
"verificationScriptFile": "q3_s3_validate_storageclass_binding_mode.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"namespace": "storage-test",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "An application team needs storage for their database. \n\nCreate a PersistentVolumeClaim named `pvc-app` in the `storage-test` namespace that will request storage from the previously created StorageClass. \n\nThe PVC should request exactly 500Mi of storage capacity and use the `ReadWriteOnce` access mode to ensure data consistency. \n\nMake sure to specify the `fast-storage` StorageClass that you created earlier as the storage class for this claim. \n\nThis PVC will be used by a database application that requires persistent storage.",
|
||||
"concepts": ["persistent-volume-claims", "storage-class", "access-modes"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "PersistentVolumeClaim is created",
|
||||
"verificationScriptFile": "q4_s1_validate_pvc_created.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "PersistentVolumeClaim requests correct storage size",
|
||||
"verificationScriptFile": "q4_s2_validate_pvc_size.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "PersistentVolumeClaim has correct access mode",
|
||||
"verificationScriptFile": "q4_s3_validate_pvc_access_mode.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"description": "PersistentVolumeClaim uses correct StorageClass",
|
||||
"verificationScriptFile": "q4_s4_validate_pvc_storageclass.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"namespace": "troubleshooting",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The development team has reported an issue with their application deployment. \n\nThe deployment `broken-app` in namespace `troubleshooting` is consistently failing to start and maintain running pods. \n\nYour task is to investigate this deployment, identify the root cause of the failure, and implement the necessary fixes to make the deployment operational. \n\nCheck for issues such as incorrect container image references, resource constraints, configuration problems, or any other factors preventing successful deployment. \n\nDocument your findings and the changes you make to fix the issue.",
|
||||
"concepts": ["deployments", "troubleshooting", "debugging"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Deployment pods are running",
|
||||
"verificationScriptFile": "q5_s1_validate_pods_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Deployment has correct container image",
|
||||
"verificationScriptFile": "q5_s2_validate_container_image.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"namespace": "troubleshooting",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The DevOps team needs a specialized pod to help with monitoring and logging. \n\nCreate a multi-container pod named `sidecar-pod` in the `troubleshooting` namespace with the following specifications: \n\n1. Main container: \n - Image: `nginx` \n - Purpose: Serve web content \n\n2. Sidecar container: \n - Image: `busybox` \n - Command: [`sh`, `-c`, `while true; do date >> /var/log/date.log; sleep 10; done`] \n\n3. Shared volume configuration: \n - Volume name: `log-volume` \n - Mount path: `/var/log` in both containers \n\nThis demonstrates the sidecar container pattern for extending application functionality.",
|
||||
"concepts": ["pods", "multi-container", "volumes", "sidecar-pattern"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Pod is created with two containers",
|
||||
"verificationScriptFile": "q6_s1_validate_multicontainer_pod.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Pod has shared volume mounted in both containers",
|
||||
"verificationScriptFile": "q6_s2_validate_shared_volume.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"namespace": "troubleshooting",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "Users are reporting connectivity issues with an application. \n\nThe Service `web-service` in namespace `troubleshooting` is supposed to route traffic to backend pods, but it is not functioning correctly. \n\nInvestigate this service and identify what is preventing proper traffic routing. Possible issues could include: \n\n- Mismatched selectors between service and pods \n- Incorrect port configurations \n- Problems with the underlying pods \n\nAfter identifying the issue, implement the necessary fixes to ensure the service correctly routes traffic to the appropriate pods. \n\nVerify your fix by ensuring that service endpoints are properly populated and traffic is forwarded as expected.",
|
||||
"concepts": ["services", "selectors", "networking", "troubleshooting"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Service selector matches pod labels",
|
||||
"verificationScriptFile": "q7_s1_validate_service_selector.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Service ports match container ports",
|
||||
"verificationScriptFile": "q7_s2_validate_service_ports.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"namespace": "troubleshooting",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The operations team has reported performance degradation in the cluster. \n\nPod `logging-pod` in namespace `troubleshooting` is consuming excessive CPU resources, affecting other workloads in the cluster. \n\nYour task is to: \n\n1. Identify which container within the pod is causing the high CPU usage \n\n2. Configure appropriate CPU limits for that container to prevent resource abuse while ensuring the application can still function \n\n3. Implement your solution by modifying the pod specification with the necessary resource constraints \n\nEnsure that the pod continues to run successfully after your changes, but with its CPU usage kept within reasonable bounds as defined by the limits you set.",
|
||||
"concepts": ["resource-limits", "resource-requests", "cpu-management", "troubleshooting"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "CPU limits are set for container",
|
||||
"verificationScriptFile": "q8_s1_validate_cpu_limits.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Pod is running",
|
||||
"verificationScriptFile": "q8_s2_validate_pod_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "CPU usage under threshold",
|
||||
"verificationScriptFile": "q8_s3_validate_cpu_usage.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"namespace": "workloads",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "A development team needs environment-specific configuration for their application. \n\nFirst, create a ConfigMap named `app-config` in namespace `workloads` containing exactly two key-value pairs: `APP_ENV=production` and `LOG_LEVEL=info`. \n\nNext, create a Pod named `config-pod` using the `nginx` image that consumes these configurations as environment variables. \n\nThe pod should be resource-efficient but have guaranteed resources, so configure it with a CPU request of `100m`, a CPU limit of `200m`, a memory request of `128Mi`, and a memory limit of `256Mi`.",
|
||||
"concepts": ["configmaps", "pods", "environment-variables", "resource-limits"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "ConfigMap is created with correct data",
|
||||
"verificationScriptFile": "q9_s1_validate_configmap.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Pod is created and running",
|
||||
"verificationScriptFile": "q9_s2_validate_pod_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Pod has environment variables from ConfigMap",
|
||||
"verificationScriptFile": "q9_s3_validate_pod_env_vars.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"description": "Pod has correct resource requirements",
|
||||
"verificationScriptFile": "q9_s4_validate_resources.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"namespace": "workloads",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The database team needs to securely deploy a MySQL instance with proper credential management. \n\nCreate a Secret named `db-credentials` in namespace `workloads` containing two sensitive values: `username=admin` and `password=securepass`. \n\nThen create a Pod named `secure-pod` using the `mysql:5.7` image that uses these credentials. \n\nConfigure the pod to access the Secret values as environment variables named `DB_USER` and `DB_PASSWORD` respectively. \n\nThis pattern demonstrates secure handling of sensitive information in Kubernetes without hardcoding credentials in the pod specification. Ensure the MySQL container is properly configured to use these environment variables for authentication.",
|
||||
"concepts": ["secrets", "pods", "environment-variables", "mysql"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Secret is created with correct data",
|
||||
"verificationScriptFile": "q10_s1_validate_secret.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Pod is created and running",
|
||||
"verificationScriptFile": "q10_s2_validate_pod_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Pod has environment variables from Secret",
|
||||
"verificationScriptFile": "q10_s3_validate_pod_env_vars.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"namespace": "workloads",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The system administration team needs an automated solution for log file cleanup. \n\nCreate a CronJob named `log-cleaner` in namespace `workloads` that will automatically manage log files based on age. \n\nConfigure it to run precisely every hour (using a standard cron expression) and use the `busybox` image. The job should execute the command `find /var/log -type f -name \"*.log\" -mtime +7 -delete` to remove log files older than 7 days. \n\nTo prevent resource contention, set the concurrency policy to `Forbid` so that new job executions are skipped if a previous execution is still running. \n\nFor job history management, configure the job to keep exactly `3` successful job completions and `1` failed job in its history.",
|
||||
"concepts": ["cronjobs", "jobs", "scheduling", "concurrency-policy"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "CronJob is created with correct name and namespace",
|
||||
"verificationScriptFile": "q11_s1_validate_cronjob_exists.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "CronJob has correct schedule",
|
||||
"verificationScriptFile": "q11_s2_validate_cronjob_schedule.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "CronJob has correct command",
|
||||
"verificationScriptFile": "q11_s3_validate_cronjob_command.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"description": "CronJob has correct concurrency policy and history limits",
|
||||
"verificationScriptFile": "q11_s4_validate_cronjob_policy.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"namespace": "workloads",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "To ensure application reliability, the team needs to implement health checking for a critical service. \n\nCreate a Pod named `health-pod` in namespace `workloads` using the `nginx` image with the following health monitoring configuration: \n\n1) A liveness probe that performs an HTTP GET request to the path `/healthz` on port `80` every 15 seconds to determine if the container is alive. If this check fails, Kubernetes will restart the container. \n\n2) A readiness probe that checks if the container is ready to serve traffic by testing if port `80` is open and accepting connections every 10 seconds. \n\nConfigure appropriate initial delay, timeout, and failure threshold values based on best practices.",
|
||||
"concepts": ["pods", "probes", "liveness-probe", "readiness-probe", "health-checks"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Pod is created and running",
|
||||
"verificationScriptFile": "q12_s1_validate_pod_running.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Pod has correct liveness probe",
|
||||
"verificationScriptFile": "q12_s2_validate_liveness_probe.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Pod has correct readiness probe",
|
||||
"verificationScriptFile": "q12_s3_validate_readiness_probe.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"namespace": "cluster-admin",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "To implement proper access control and security segmentation in the cluster, you need to configure RBAC resources. \n\nFirst, create a `ClusterRole` named `pod-reader` that defines a set of permissions for pod operations. This role should specifically allow three operations on pods: `get` (view individual pods), `watch` (receive notifications about pod changes), and `list` (view collections of pods). \n\nNext, create a `ClusterRoleBinding` named `read-pods` that associates this role with the user `jane` in the namespace `cluster-admin`. \n\nThis binding will grant user `Jane` read-only access to pod resources across all namespaces in the cluster, following the principle of least privilege while allowing her to perform her monitoring duties.",
|
||||
"concepts": ["rbac", "cluster-role", "cluster-role-binding", "authorization"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "ClusterRole is created with correct permissions",
|
||||
"verificationScriptFile": "q13_s1_validate_clusterrole.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "ClusterRoleBinding is created correctly",
|
||||
"verificationScriptFile": "q13_s2_validate_clusterrolebinding.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"namespace": "cluster-admin",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "Deploy the Bitnami Nginx chart in the `web` namespace using Helm. \n\nFirst, add the Bitnami repository (`https://charts.bitnami.com/bitnami`) if not already present. \n\nThen, deploy the `Bitnami` `nginx` chart with exactly `2` replicas to ensure high availability. \n\nVerify the deployment is successful by checking that pods are running and the service is correctly configured.",
|
||||
"concepts": ["helm", "package-management", "nginx", "services"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Bitnami repository is added",
|
||||
"verificationScriptFile": "q14_s1_validate_helm_repo.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Bitnami Nginx is deployed with correct configuration",
|
||||
"verificationScriptFile": "q14_s2_validate_nginx_deployed.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"namespace": "cluster-admin",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "To support a custom backup solution, you need to extend the Kubernetes API. \n\nCreate a CustomResourceDefinition (CRD) named `backups.data.example.com` that defines a new resource type `Backup` in API group `data.example.com` with version `v1alpha1`. \n\nThis custom resource should include a schema that validates two required fields: `spec.source` (a string representing the source data location) and `spec.destination` (a string representing where backups should be stored). \n\nConfigure appropriate additional fields like shortNames, scope, and descriptions to make this CRD user-friendly.",
|
||||
"concepts": ["custom-resource-definitions", "api-extensions", "crds", "api-groups"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "CRD is created with correct API group and version",
|
||||
"verificationScriptFile": "q15_s1_validate_crd_api.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "CRD has required fields in schema",
|
||||
"verificationScriptFile": "q15_s2_validate_crd_schema.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "16",
|
||||
"namespace": "networking",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "To enhance security through network segmentation, implement a network policy for a web application. \n\nCreate a NetworkPolicy named `allow-traffic` in namespace `networking` that allows incoming traffic only from pods with the label `tier=frontend` to pods with the label `app=web` on TCP port `80`. \n\nAll other incoming traffic to these pods should be denied. \n\nThis implements the principle of least privilege at the network level, ensuring that the web application can only be accessed by authorized frontend components.",
|
||||
"concepts": ["network-policies", "pod-security", "network-security", "labels"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "NetworkPolicy is created",
|
||||
"verificationScriptFile": "q16_s1_validate_policy_created.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "NetworkPolicy has correct pod selector",
|
||||
"verificationScriptFile": "q16_s2_validate_pod_selector.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "NetworkPolicy has correct ingress rules",
|
||||
"verificationScriptFile": "q16_s3_validate_policy_rules.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
"namespace": "networking",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The microservices architecture requires internal service communication. \n\nCreate a ClusterIP service named `internal-app` in namespace `networking` to enable this communication pattern. \n\nConfigure the service to route traffic to pods with the label `app=backend`. The service should accept connections on port `80` and forward them to port `8080` on the backend pods. \n\nClusterIP is the appropriate service type because this communication is entirely internal to the cluster and doesn`t need external exposure. \n\nEnsure the selector exactly matches the pod labels and the port configuration correctly maps the service port to the target port on the pods.",
|
||||
"concepts": ["services", "clusterip", "networking", "selectors"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Service is created with correct type",
|
||||
"verificationScriptFile": "q17_s1_validate_service_type.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Service has correct selector",
|
||||
"verificationScriptFile": "q17_s2_validate_service_selector.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Service has correct port configuration",
|
||||
"verificationScriptFile": "q17_s3_validate_service_ports.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "18",
|
||||
"namespace": "networking",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "A public-facing web application needs to be exposed to external users. \n\nCreate a NodePort service named `public-web` in namespace `networking` that will expose the `web-frontend` deployment to external users. \n\nConfigure the service to accept external traffic on port `80` and forward it to port `8080` on the deployment`s pods. Set the NodePort to `30080`. \n\nUsing a `NodePort` service will expose the application on a static port on each node in the cluster, making it accessible via any node`s IP address. \n\nEnsure the service selector correctly targets the `web-frontend` deployment pods and that the port configuration is appropriate for a web application. \n\nThis setup will enable external users to access the web application through `<node-ip>:30080`.",
|
||||
"concepts": ["services", "nodeport", "networking", "exposing-apps"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Service is created with correct type",
|
||||
"verificationScriptFile": "q18_s1_validate_service_type.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Service has correct selector",
|
||||
"verificationScriptFile": "q18_s2_validate_service_selector.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Service has correct port configuration",
|
||||
"verificationScriptFile": "q18_s3_validate_service_ports.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
"namespace": "networking",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "The API team needs to implement host-based routing for their services. \n\nCreate an Ingress resource named `api-ingress` in namespace `networking` that implements the following routing rule: \n\n- All HTTP traffic for the hostname `api.example.com` should be directed to the service `api-service` on port `80`. \n\nThis Ingress will utilize the cluster`s ingress controller to provide more sophisticated HTTP routing than is possible with Services alone. \n\nMake sure to properly configure the host field with the exact domain name and set up the correct backend service reference.",
|
||||
"concepts": ["ingress", "networking", "host-based-routing", "http-routing"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Ingress is created",
|
||||
"verificationScriptFile": "q19_s1_validate_ingress_created.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 1
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Ingress has correct host",
|
||||
"verificationScriptFile": "q19_s2_validate_ingress_host.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"description": "Ingress has correct service backend",
|
||||
"verificationScriptFile": "q19_s3_validate_ingress_backend.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "20",
|
||||
"namespace": "networking",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "A one-time configuration backup operation needs to be performed. \n\nCreate a Kubernetes Job named `backup-job` in the `networking` namespace to handle this task. \n\nThe job should create a single pod using the `busybox` image with a command that copies all files from the directory `/etc/config` to the `/backup` directory. \n\nConfigure the job with the following specifications: \n\n- Set `restartPolicy: Never` to ensure that containers are not restarted after completion or failure \n- Set `backoffLimit: 0` so that the job will not be retried if it fails \n\nThis job represents a one-time, batch operation that should either complete successfully or fail without retries, allowing administrators to then investigate any issues manually. \n\nVerify that the job completes successfully and that the files are properly copied to the destination directory.",
|
||||
"concepts": ["jobs", "batch-processing", "pods", "containers"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "Job is created and completes successfully",
|
||||
"verificationScriptFile": "q20_s1_validate_job_completed.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Job has correct configuration",
|
||||
"verificationScriptFile": "q20_s2_validate_job_config.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "21",
|
||||
"namespace": "docker-oci",
|
||||
"machineHostname": "ckad9999",
|
||||
"question": "For improved container image distribution and portability, you need to work with the Open Container Initiative (OCI) format. \n\nComplete the following steps: \n\n1. Pull the `nginx:latest` image from Docker Hub using the `docker pull` command \n\n2. Create a directory at `/root/oci-images` if it doesn`t already exist \n\n3. Using the appropriate docker commands, export the nginx image in OCI format and store it in this directory \n\nThis will help standardize the container image format and improve portability across different container runtimes.",
|
||||
"concepts": ["containers", "images", "oci-format", "docker"],
|
||||
"verification": [
|
||||
{
|
||||
"id": "1",
|
||||
"description": "OCI directory exists with image content",
|
||||
"verificationScriptFile": "q21_s1_validate_oci_dir_exists.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 2
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"description": "Nginx image is properly stored in OCI format",
|
||||
"verificationScriptFile": "q21_s2_validate_nginx_image.sh",
|
||||
"expectedOutput": "0",
|
||||
"weightage": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
10
facilitator/assets/exams/ckad/001/config.json
Normal file
10
facilitator/assets/exams/ckad/001/config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"lab": "ckad-001",
|
||||
"workerNodes":1 ,
|
||||
"answers": "assets/exams/ckad/001/answers.md",
|
||||
"questions": "assessment.json",
|
||||
"totalMarks": 100,
|
||||
"lowScore": 40,
|
||||
"mediumScore": 60,
|
||||
"highScore": 90
|
||||
}
|
||||
15
facilitator/assets/exams/ckad/001/scripts/setup/q10_setup.sh
Executable file
15
facilitator/assets/exams/ckad/001/scripts/setup/q10_setup.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 10: Create a Secret and use it in a Pod
|
||||
|
||||
# Create the workloads namespace if it doesn't exist already
|
||||
if ! kubectl get namespace workloads &> /dev/null; then
|
||||
kubectl create namespace workloads
|
||||
fi
|
||||
|
||||
# Delete any existing Secret and Pod with the same names
|
||||
kubectl delete secret db-credentials -n workloads --ignore-not-found=true
|
||||
kubectl delete pod secure-pod -n workloads --ignore-not-found=true
|
||||
|
||||
echo "Setup complete for Question 10: Environment ready for creating Secret 'db-credentials' and Pod 'secure-pod'"
|
||||
exit 0
|
||||
23
facilitator/assets/exams/ckad/001/scripts/setup/q11_setup.sh
Executable file
23
facilitator/assets/exams/ckad/001/scripts/setup/q11_setup.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 11: Create a CronJob for log cleaning
|
||||
|
||||
# Create the workloads namespace if it doesn't exist already
|
||||
if ! kubectl get namespace workloads &> /dev/null; then
|
||||
kubectl create namespace workloads
|
||||
fi
|
||||
|
||||
# Delete any existing CronJob with the same name
|
||||
kubectl delete cronjob log-cleaner -n workloads --ignore-not-found=true
|
||||
|
||||
# Create a directory with some sample log files for demonstration
|
||||
mkdir -p /tmp/var/log
|
||||
touch /tmp/var/log/test1.log
|
||||
touch /tmp/var/log/test2.log
|
||||
touch /tmp/var/log/app.log
|
||||
touch /tmp/var/log/system.log
|
||||
|
||||
echo "Setup complete for Question 11: Environment ready for creating CronJob 'log-cleaner'"
|
||||
echo "Note: In a real environment, log files would be on the host system. These sample files"
|
||||
echo " are for demonstration only and won't actually be accessible from the CronJob."
|
||||
exit 0
|
||||
32
facilitator/assets/exams/ckad/001/scripts/setup/q12_setup.sh
Executable file
32
facilitator/assets/exams/ckad/001/scripts/setup/q12_setup.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 12: Create a Pod with liveness and readiness probes
|
||||
|
||||
# Create the workloads namespace if it doesn't exist already
|
||||
if ! kubectl get namespace workloads &> /dev/null; then
|
||||
kubectl create namespace workloads
|
||||
fi
|
||||
|
||||
# Delete any existing Pod with the same name
|
||||
kubectl delete pod health-pod -n workloads --ignore-not-found=true
|
||||
|
||||
# Create an index.html and healthz endpoint for testing the probes
|
||||
cat <<EOF > /tmp/index.html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>CKAD Exam</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to the CKAD Practice Exam!</h1>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
cat <<EOF > /tmp/healthz
|
||||
OK
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 12: Environment ready for creating Pod 'health-pod' with liveness and readiness probes"
|
||||
echo "Note: In a real environment, you would need to set up files at /healthz in the container."
|
||||
exit 0
|
||||
17
facilitator/assets/exams/ckad/001/scripts/setup/q14_setup.sh
Normal file
17
facilitator/assets/exams/ckad/001/scripts/setup/q14_setup.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 14: Install Helm and deploy Bitnami Nginx
|
||||
|
||||
# Create the web namespace if it doesn't exist already
|
||||
if ! kubectl get namespace web &> /dev/null; then
|
||||
kubectl create namespace web
|
||||
fi
|
||||
|
||||
# Delete any existing helm installations of nginx
|
||||
if command -v helm &> /dev/null; then
|
||||
helm uninstall nginx -n web --ignore-not-found
|
||||
fi
|
||||
|
||||
echo "Setup complete for Question 14: Environment ready for installing Helm and deploying Bitnami Nginx"
|
||||
echo "Note: The candidate should add the Bitnami repo if not already present: helm repo add bitnami https://charts.bitnami.com/bitnami"
|
||||
exit 0
|
||||
67
facilitator/assets/exams/ckad/001/scripts/setup/q16_setup.sh
Executable file
67
facilitator/assets/exams/ckad/001/scripts/setup/q16_setup.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 16: Create a NetworkPolicy
|
||||
|
||||
# Create the networking namespace if it doesn't exist already
|
||||
if ! kubectl get namespace networking &> /dev/null; then
|
||||
kubectl create namespace networking
|
||||
fi
|
||||
|
||||
# Delete any existing NetworkPolicy with the same name
|
||||
kubectl delete networkpolicy allow-traffic -n networking --ignore-not-found=true
|
||||
|
||||
# Create pods with the required labels for testing the network policy
|
||||
kubectl delete pod -l app=web -n networking --ignore-not-found=true
|
||||
kubectl delete pod -l tier=frontend -n networking --ignore-not-found=true
|
||||
|
||||
# Create a web pod with label app=web
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: web-pod
|
||||
namespace: networking
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
|
||||
# Create a frontend pod with label tier=frontend
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: frontend-pod
|
||||
namespace: networking
|
||||
labels:
|
||||
tier: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: busybox
|
||||
image: busybox
|
||||
command: ["sleep", "3600"]
|
||||
EOF
|
||||
|
||||
# Create a pod with no relevant labels for testing isolation
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: other-pod
|
||||
namespace: networking
|
||||
labels:
|
||||
tier: other
|
||||
spec:
|
||||
containers:
|
||||
- name: busybox
|
||||
image: busybox
|
||||
command: ["sleep", "3600"]
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 16: Created pods with necessary labels for NetworkPolicy testing"
|
||||
exit 0
|
||||
41
facilitator/assets/exams/ckad/001/scripts/setup/q17_setup.sh
Executable file
41
facilitator/assets/exams/ckad/001/scripts/setup/q17_setup.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 17: Create a ClusterIP service
|
||||
|
||||
# Create the networking namespace if it doesn't exist already
|
||||
if ! kubectl get namespace networking &> /dev/null; then
|
||||
kubectl create namespace networking
|
||||
fi
|
||||
|
||||
# Delete any existing service with the same name
|
||||
kubectl delete service internal-app -n networking --ignore-not-found=true
|
||||
|
||||
# Create backend pods with the required labels
|
||||
kubectl delete deployment backend-app -n networking --ignore-not-found=true
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend-app
|
||||
namespace: networking
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
command: ["/bin/sh", "-c"]
|
||||
args: ["nginx -g 'daemon off;' & echo 'Backend service running on port 8080' && sleep infinity"]
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 17: Created backend pods with label app=backend for the ClusterIP service"
|
||||
exit 0
|
||||
39
facilitator/assets/exams/ckad/001/scripts/setup/q18_setup.sh
Executable file
39
facilitator/assets/exams/ckad/001/scripts/setup/q18_setup.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 18: Create a LoadBalancer service
|
||||
|
||||
# Create the networking namespace if it doesn't exist already
|
||||
if ! kubectl get namespace networking &> /dev/null; then
|
||||
kubectl create namespace networking
|
||||
fi
|
||||
|
||||
# Delete any existing service with the same name
|
||||
kubectl delete service public-web -n networking --ignore-not-found=true
|
||||
|
||||
# Create a deployment to be exposed by the LoadBalancer service
|
||||
kubectl delete deployment web-frontend -n networking --ignore-not-found=true
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-frontend
|
||||
namespace: networking
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: web-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 18: Created deployment 'web-frontend' for the LoadBalancer service"
|
||||
exit 0
|
||||
57
facilitator/assets/exams/ckad/001/scripts/setup/q19_setup.sh
Executable file
57
facilitator/assets/exams/ckad/001/scripts/setup/q19_setup.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 19: Create an Ingress resource
|
||||
|
||||
# Create the networking namespace if it doesn't exist already
|
||||
if ! kubectl get namespace networking &> /dev/null; then
|
||||
kubectl create namespace networking
|
||||
fi
|
||||
|
||||
# Delete any existing Ingress with the same name
|
||||
kubectl delete ingress api-ingress -n networking --ignore-not-found=true
|
||||
|
||||
# Create a service to be used by the Ingress
|
||||
kubectl delete service api-service -n networking --ignore-not-found=true
|
||||
kubectl delete deployment api-backend -n networking --ignore-not-found=true
|
||||
|
||||
# Create a deployment for the API service
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api-backend
|
||||
namespace: networking
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
|
||||
# Create a service for the API deployment
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: api-service
|
||||
namespace: networking
|
||||
spec:
|
||||
selector:
|
||||
app: api
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 19: Created service 'api-service' for the Ingress resource"
|
||||
exit 0
|
||||
14
facilitator/assets/exams/ckad/001/scripts/setup/q1_setup.sh
Executable file
14
facilitator/assets/exams/ckad/001/scripts/setup/q1_setup.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 1: Create a deployment called nginx-deployment in namespace dev
|
||||
|
||||
# Create the namespace if it doesn't exist already
|
||||
if kubectl get namespace dev &> /dev/null; then
|
||||
kubectl delete namespace dev --ignore-not-found=true
|
||||
fi
|
||||
|
||||
# Delete any existing deployment with the same name to ensure a clean state
|
||||
kubectl delete deployment nginx-deployment -n dev --ignore-not-found=true
|
||||
|
||||
echo "Setup complete for Question 1: Environment ready for creating nginx deployment in namespace 'dev'"
|
||||
exit 0
|
||||
40
facilitator/assets/exams/ckad/001/scripts/setup/q20_setup.sh
Normal file
40
facilitator/assets/exams/ckad/001/scripts/setup/q20_setup.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 20: Create a Job to backup configuration files
|
||||
|
||||
# Create the networking namespace if it doesn't exist already
|
||||
if ! kubectl get namespace networking &> /dev/null; then
|
||||
kubectl create namespace networking
|
||||
fi
|
||||
|
||||
# Delete any existing job with the same name
|
||||
kubectl delete job backup-job -n networking --ignore-not-found=true
|
||||
|
||||
# Create a ConfigMap with sample configuration files
|
||||
kubectl delete configmap example-config -n networking --ignore-not-found=true
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: example-config
|
||||
namespace: networking
|
||||
data:
|
||||
nginx.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
}
|
||||
app.conf: |
|
||||
log_level=debug
|
||||
port=8080
|
||||
max_connections=100
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 20: Environment ready for creating backup Job"
|
||||
echo "Note: The example ConfigMap is just for reference. In a real environment,"
|
||||
echo " the student would need to create a Pod that mounts both /etc/config and /backup directories."
|
||||
exit 0
|
||||
22
facilitator/assets/exams/ckad/001/scripts/setup/q21_setup.sh
Normal file
22
facilitator/assets/exams/ckad/001/scripts/setup/q21_setup.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 22: Pull and store nginx image in OCI format
|
||||
|
||||
# Create the directory for storing OCI images
|
||||
mkdir -p /root/oci-images
|
||||
|
||||
# Remove any existing content to ensure clean state
|
||||
rm -rf /root/oci-images/*
|
||||
|
||||
# Make sure required tools are installed
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Installing docker for image pulling..."
|
||||
apt-get update
|
||||
apt-get install -y docker.io
|
||||
systemctl start docker
|
||||
fi
|
||||
|
||||
echo "Setup complete for Question 22: Environment ready for pulling and storing the nginx image in OCI format"
|
||||
echo "Task: Pull the nginx:latest image and store it in OCI format in the directory /root/oci-images"
|
||||
exit 0
|
||||
18
facilitator/assets/exams/ckad/001/scripts/setup/q2_setup.sh
Executable file
18
facilitator/assets/exams/ckad/001/scripts/setup/q2_setup.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 2: Create a PersistentVolume named 'pv-storage'
|
||||
|
||||
# Create the storage-test namespace if it doesn't exist already
|
||||
if ! kubectl get namespace storage-test &> /dev/null; then
|
||||
kubectl create namespace storage-test
|
||||
fi
|
||||
|
||||
# Delete any existing PV with the same name to ensure a clean state
|
||||
kubectl delete pv pv-storage --ignore-not-found=true
|
||||
|
||||
# Create the /mnt/data directory on the host if possible (this may require privileged access)
|
||||
# In a real environment, this would need to be handled by the cluster admin
|
||||
echo "Note: Ensure /mnt/data directory exists on the node for the hostPath volume"
|
||||
|
||||
echo "Setup complete for Question 2: Environment ready for creating PersistentVolume 'pv-storage'"
|
||||
exit 0
|
||||
9
facilitator/assets/exams/ckad/001/scripts/setup/q3_setup.sh
Executable file
9
facilitator/assets/exams/ckad/001/scripts/setup/q3_setup.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 3: Create a StorageClass named 'fast-storage'
|
||||
|
||||
# Delete any existing StorageClass with the same name to ensure a clean state
|
||||
kubectl delete storageclass slow-storage --ignore-not-found=true
|
||||
|
||||
echo "Setup complete for Question 3: Environment ready for creating StorageClass 'fast-storage'"
|
||||
exit 0
|
||||
27
facilitator/assets/exams/ckad/001/scripts/setup/q4_setup.sh
Executable file
27
facilitator/assets/exams/ckad/001/scripts/setup/q4_setup.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 4: Create a PersistentVolumeClaim named 'pvc-app'
|
||||
|
||||
# Create the storage-test namespace if it doesn't exist already
|
||||
if ! kubectl get namespace storage-test &> /dev/null; then
|
||||
kubectl create namespace storage-test
|
||||
fi
|
||||
|
||||
# Delete any existing PVC with the same name to ensure a clean state
|
||||
kubectl delete pvc pvc-app -n storage-test --ignore-not-found=true
|
||||
|
||||
# Create the StorageClass if it doesn't exist (dependency for this question)
|
||||
# if ! kubectl get storageclass fast-storage &> /dev/null; then
|
||||
# cat <<EOF | kubectl apply -f -
|
||||
# apiVersion: storage.k8s.io/v1
|
||||
# kind: StorageClass
|
||||
# metadata:
|
||||
# name: fast-storage
|
||||
# provisioner: kubernetes.io/no-provisioner
|
||||
# volumeBindingMode: WaitForFirstConsumer
|
||||
# EOF
|
||||
# echo "Created dependency: StorageClass 'fast-storage'"
|
||||
# fi
|
||||
|
||||
echo "Setup complete for Question 4: Environment ready for creating PersistentVolumeClaim 'pvc-app'"
|
||||
exit 0
|
||||
38
facilitator/assets/exams/ckad/001/scripts/setup/q5_setup.sh
Executable file
38
facilitator/assets/exams/ckad/001/scripts/setup/q5_setup.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 5: Troubleshoot and fix a broken deployment
|
||||
|
||||
# Create the troubleshooting namespace if it doesn't exist already
|
||||
if ! kubectl get namespace troubleshooting &> /dev/null; then
|
||||
kubectl create namespace troubleshooting
|
||||
fi
|
||||
|
||||
# Delete any existing deployment with the same name
|
||||
kubectl delete deployment broken-app -n troubleshooting --ignore-not-found=true
|
||||
|
||||
# Create a broken deployment with an invalid image name
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: broken-app
|
||||
namespace: troubleshooting
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: broken-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: broken-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:nonexistentversion # This image tag doesn't exist
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 5: Created broken deployment 'broken-app' in namespace 'troubleshooting'"
|
||||
exit 0
|
||||
14
facilitator/assets/exams/ckad/001/scripts/setup/q6_setup.sh
Executable file
14
facilitator/assets/exams/ckad/001/scripts/setup/q6_setup.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 6: Multi-container pod with shared volume
|
||||
|
||||
# Create the troubleshooting namespace if it doesn't exist already
|
||||
if ! kubectl get namespace troubleshooting &> /dev/null; then
|
||||
kubectl create namespace troubleshooting
|
||||
fi
|
||||
|
||||
# Delete any existing pod with the same name
|
||||
kubectl delete pod sidecar-pod -n troubleshooting --ignore-not-found=true
|
||||
|
||||
echo "Setup complete for Question 6: Environment ready for creating a multi-container pod with shared volume"
|
||||
exit 0
|
||||
54
facilitator/assets/exams/ckad/001/scripts/setup/q7_setup.sh
Executable file
54
facilitator/assets/exams/ckad/001/scripts/setup/q7_setup.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 7: Service with incorrect selector not routing traffic to pods
|
||||
|
||||
# Create the troubleshooting namespace if it doesn't exist already
|
||||
if ! kubectl get namespace troubleshooting &> /dev/null; then
|
||||
kubectl create namespace troubleshooting
|
||||
fi
|
||||
|
||||
# Delete any existing resources with the same names
|
||||
kubectl delete service web-service -n troubleshooting --ignore-not-found=true
|
||||
kubectl delete deployment web-app -n troubleshooting --ignore-not-found=true
|
||||
|
||||
# Create a deployment with label app=web-app
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-app
|
||||
namespace: troubleshooting
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: web-app
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
|
||||
# Create a service with incorrect selector (app=web instead of app=web-app)
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: web-service
|
||||
namespace: troubleshooting
|
||||
spec:
|
||||
selector:
|
||||
app: web # Incorrect selector, should be app=web-app
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 7: Created service 'web-service' with incorrect selector"
|
||||
exit 0
|
||||
34
facilitator/assets/exams/ckad/001/scripts/setup/q8_setup.sh
Executable file
34
facilitator/assets/exams/ckad/001/scripts/setup/q8_setup.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 8: Pod with high CPU usage
|
||||
|
||||
# Create the troubleshooting namespace if it doesn't exist already
|
||||
if ! kubectl get namespace troubleshooting &> /dev/null; then
|
||||
kubectl create namespace troubleshooting
|
||||
fi
|
||||
|
||||
# Delete any existing pod with the same name
|
||||
kubectl delete pod logging-pod -n troubleshooting --ignore-not-found=true
|
||||
|
||||
# Create a pod with a container that has high CPU usage and no resource limits
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: logging-pod
|
||||
namespace: troubleshooting
|
||||
spec:
|
||||
containers:
|
||||
- name: cpu-consumer
|
||||
image: busybox
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- "while true; do echo 'Consuming CPU...'; done"
|
||||
- name: normal-container
|
||||
image: nginx
|
||||
EOF
|
||||
|
||||
echo "Setup complete for Question 8: Created pod 'logging-pod' with high CPU usage container"
|
||||
echo "Note: In a real environment, the 'cpu-consumer' container would actually consume high CPU."
|
||||
echo " The student needs to identify this container and set appropriate CPU limits."
|
||||
exit 0
|
||||
15
facilitator/assets/exams/ckad/001/scripts/setup/q9_setup.sh
Executable file
15
facilitator/assets/exams/ckad/001/scripts/setup/q9_setup.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup for Question 9: Create a ConfigMap and use it in a Pod
|
||||
|
||||
# Create the workloads namespace if it doesn't exist already
|
||||
if ! kubectl get namespace workloads &> /dev/null; then
|
||||
kubectl create namespace workloads
|
||||
fi
|
||||
|
||||
# Delete any existing ConfigMap and Pod with the same names
|
||||
kubectl delete configmap app-config -n workloads --ignore-not-found=true
|
||||
kubectl delete pod config-pod -n workloads --ignore-not-found=true
|
||||
|
||||
echo "Setup complete for Question 9: Environment ready for creating ConfigMap 'app-config' and Pod 'config-pod'"
|
||||
exit 0
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the Secret 'db-credentials' exists in the 'workloads' namespace with correct data
|
||||
USERNAME=$(kubectl get secret db-credentials -n workloads -o jsonpath='{.data.username}' 2>/dev/null | base64 --decode)
|
||||
PASSWORD=$(kubectl get secret db-credentials -n workloads -o jsonpath='{.data.password}' 2>/dev/null | base64 --decode)
|
||||
|
||||
if [ "$USERNAME" = "admin" ] && [ "$PASSWORD" = "securepass" ]; then
|
||||
echo "Success: Secret 'db-credentials' exists with correct data"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: Secret 'db-credentials' does not have the correct data."
|
||||
echo "Expected: username=admin, password=securepass"
|
||||
echo "Found: username=$USERNAME, password=$PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the CronJob 'log-cleaner' exists in the 'workloads' namespace
|
||||
if kubectl get cronjob log-cleaner -n workloads &> /dev/null; then
|
||||
echo "Success: CronJob 'log-cleaner' exists in namespace 'workloads'"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: CronJob 'log-cleaner' does not exist in namespace 'workloads'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the CronJob 'log-cleaner' has the correct schedule (every hour)
|
||||
SCHEDULE=$(kubectl get cronjob log-cleaner -n workloads -o jsonpath='{.spec.schedule}' 2>/dev/null)
|
||||
|
||||
if [ "$SCHEDULE" = "0 * * * *" ] || [ "$SCHEDULE" = "@hourly" ]; then
|
||||
echo "Success: CronJob 'log-cleaner' has the correct schedule (every hour): $SCHEDULE"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: CronJob 'log-cleaner' does not have the correct schedule"
|
||||
echo "Expected: '0 * * * *' or '@hourly', Found: '$SCHEDULE'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# Validate that the 'health-pod' is running in namespace 'workloads'
|
||||
|
||||
NAMESPACE="workloads"
|
||||
POD_NAME="health-pod"
|
||||
|
||||
# Check if the pod exists
|
||||
POD_STATUS=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.status.phase}' 2>/dev/null)
|
||||
|
||||
if [ -z "$POD_STATUS" ]; then
|
||||
echo "❌ Pod '$POD_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the pod is running
|
||||
if [ "$POD_STATUS" != "Running" ]; then
|
||||
echo "❌ Pod '$POD_NAME' exists but is not running (current status: $POD_STATUS)"
|
||||
|
||||
# Get additional details about non-running pod
|
||||
CONTAINER_STATUSES=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.status.containerStatuses[*].state}' 2>/dev/null)
|
||||
|
||||
if [ -n "$CONTAINER_STATUSES" ]; then
|
||||
echo "Container statuses: $CONTAINER_STATUSES"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if all containers are ready
|
||||
READY_COUNT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.status.containerStatuses[*].ready}' | grep -o "true" | wc -l)
|
||||
CONTAINER_COUNT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[*].name}' | wc -w)
|
||||
|
||||
if [ "$READY_COUNT" -ne "$CONTAINER_COUNT" ]; then
|
||||
echo "❌ Pod '$POD_NAME' is running, but not all containers are ready ($READY_COUNT of $CONTAINER_COUNT ready)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All checks passed
|
||||
echo "✅ Pod '$POD_NAME' is running successfully in namespace '$NAMESPACE'"
|
||||
exit 0
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# Validate that the 'health-pod' in namespace 'workloads' has a correctly configured liveness probe
|
||||
|
||||
NAMESPACE="workloads"
|
||||
POD_NAME="health-pod"
|
||||
|
||||
# Check if the pod exists
|
||||
kubectl get pod $POD_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Pod '$POD_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if liveness probe is configured
|
||||
LIVENESS_PROBE=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe}' 2>/dev/null)
|
||||
|
||||
if [ -z "$LIVENESS_PROBE" ]; then
|
||||
echo "❌ Liveness probe not configured for pod '$POD_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if liveness probe is using HTTP GET
|
||||
HTTP_GET=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.httpGet}' 2>/dev/null)
|
||||
|
||||
if [ -z "$HTTP_GET" ]; then
|
||||
echo "❌ Liveness probe is not configured to use HTTP GET method"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the path
|
||||
PROBE_PATH=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.httpGet.path}' 2>/dev/null)
|
||||
|
||||
if [ "$PROBE_PATH" != "/healthz" ]; then
|
||||
echo "❌ Liveness probe path is not configured correctly. Expected '/healthz', got '$PROBE_PATH'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the port
|
||||
PROBE_PORT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.httpGet.port}' 2>/dev/null)
|
||||
|
||||
if [ "$PROBE_PORT" != "80" ] && [ "$PROBE_PORT" != "http" ]; then
|
||||
echo "❌ Liveness probe port is not configured correctly. Expected '80' or 'http', got '$PROBE_PORT'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check period seconds (should be 15s as specified in the question)
|
||||
PERIOD_SECONDS=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.periodSeconds}' 2>/dev/null)
|
||||
|
||||
if [ -z "$PERIOD_SECONDS" ]; then
|
||||
PERIOD_SECONDS="10" # Default value if not specified
|
||||
fi
|
||||
|
||||
if [ "$PERIOD_SECONDS" != "15" ]; then
|
||||
echo "⚠️ Liveness probe periodSeconds is set to '$PERIOD_SECONDS', but '15' was specified in the requirements"
|
||||
fi
|
||||
|
||||
# All checks passed
|
||||
echo "✅ Liveness probe correctly configured to perform HTTP GET requests to '/healthz' on port '$PROBE_PORT'"
|
||||
echo "✅ Probe configured with periodSeconds: $PERIOD_SECONDS"
|
||||
|
||||
# Additional probe details for informational purposes
|
||||
INITIAL_DELAY=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.initialDelaySeconds}' 2>/dev/null)
|
||||
TIMEOUT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.timeoutSeconds}' 2>/dev/null)
|
||||
FAILURE_THRESHOLD=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].livenessProbe.failureThreshold}' 2>/dev/null)
|
||||
|
||||
echo "ℹ️ Additional probe settings - initialDelaySeconds: ${INITIAL_DELAY:-N/A}, timeoutSeconds: ${TIMEOUT:-N/A}, failureThreshold: ${FAILURE_THRESHOLD:-N/A}"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Validate that the 'health-pod' in namespace 'workloads' has a correctly configured readiness probe
|
||||
|
||||
NAMESPACE="workloads"
|
||||
POD_NAME="health-pod"
|
||||
|
||||
# Check if the pod exists
|
||||
kubectl get pod $POD_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Pod '$POD_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if readiness probe is configured
|
||||
READINESS_PROBE=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe}' 2>/dev/null)
|
||||
|
||||
if [ -z "$READINESS_PROBE" ]; then
|
||||
echo "❌ Readiness probe not configured for pod '$POD_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The readiness probe should be a TCP socket check
|
||||
TCP_SOCKET=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.tcpSocket}' 2>/dev/null)
|
||||
|
||||
if [ -z "$TCP_SOCKET" ]; then
|
||||
echo "❌ Readiness probe is not configured to use TCP socket check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the port
|
||||
PROBE_PORT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.tcpSocket.port}' 2>/dev/null)
|
||||
|
||||
if [ "$PROBE_PORT" != "80" ] && [ "$PROBE_PORT" != "http" ]; then
|
||||
echo "❌ Readiness probe port is not configured correctly. Expected '80' or 'http', got '$PROBE_PORT'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check period seconds (should be 10s as specified in the question)
|
||||
PERIOD_SECONDS=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.periodSeconds}' 2>/dev/null)
|
||||
|
||||
if [ -z "$PERIOD_SECONDS" ]; then
|
||||
PERIOD_SECONDS="10" # Default value if not specified
|
||||
fi
|
||||
|
||||
if [ "$PERIOD_SECONDS" != "10" ]; then
|
||||
echo "⚠️ Readiness probe periodSeconds is set to '$PERIOD_SECONDS', but '10' was specified in the requirements"
|
||||
fi
|
||||
|
||||
# All checks passed
|
||||
echo "✅ Readiness probe correctly configured to check TCP port $PROBE_PORT"
|
||||
echo "✅ Probe configured with periodSeconds: $PERIOD_SECONDS"
|
||||
|
||||
# Additional probe details for informational purposes
|
||||
INITIAL_DELAY=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.initialDelaySeconds}' 2>/dev/null)
|
||||
TIMEOUT=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.timeoutSeconds}' 2>/dev/null)
|
||||
FAILURE_THRESHOLD=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[0].readinessProbe.failureThreshold}' 2>/dev/null)
|
||||
|
||||
echo "ℹ️ Additional probe settings - initialDelaySeconds: ${INITIAL_DELAY:-N/A}, timeoutSeconds: ${TIMEOUT:-N/A}, failureThreshold: ${FAILURE_THRESHOLD:-N/A}"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Validate that the ClusterRole 'pod-reader' has the correct permissions for pod operations
|
||||
|
||||
CLUSTERROLE_NAME="pod-reader"
|
||||
|
||||
# Check if the ClusterRole exists
|
||||
kubectl get clusterrole $CLUSTERROLE_NAME > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ClusterRole '$CLUSTERROLE_NAME' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the ClusterRole has rules for pods
|
||||
RESOURCE_PODS=$(kubectl get clusterrole $CLUSTERROLE_NAME -o jsonpath='{.rules[*].resources}' | grep -o "pods" | wc -l)
|
||||
|
||||
if [ $RESOURCE_PODS -eq 0 ]; then
|
||||
echo "❌ ClusterRole '$CLUSTERROLE_NAME' does not have rules for 'pods' resource"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for the required verbs: get, watch, and list
|
||||
VERBS=$(kubectl get clusterrole $CLUSTERROLE_NAME -o jsonpath='{.rules[?(@.resources[*]=="pods")].verbs[*]}')
|
||||
|
||||
echo "🔍 Verifying required verbs in ClusterRole '$CLUSTERROLE_NAME'..."
|
||||
|
||||
# Check for 'get' verb
|
||||
if [[ ! $VERBS =~ "get" ]]; then
|
||||
echo "❌ ClusterRole '$CLUSTERROLE_NAME' is missing 'get' verb for pods"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for 'watch' verb
|
||||
if [[ ! $VERBS =~ "watch" ]]; then
|
||||
echo "❌ ClusterRole '$CLUSTERROLE_NAME' is missing 'watch' verb for pods"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for 'list' verb
|
||||
if [[ ! $VERBS =~ "list" ]]; then
|
||||
echo "❌ ClusterRole '$CLUSTERROLE_NAME' is missing 'list' verb for pods"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure that the ClusterRole doesn't provide excessive permissions
|
||||
RESOURCE_COUNT=$(kubectl get clusterrole $CLUSTERROLE_NAME -o jsonpath='{.rules[*].resources}' | wc -w)
|
||||
VERB_COUNT=$(kubectl get clusterrole $CLUSTERROLE_NAME -o jsonpath='{.rules[*].verbs}' | wc -w)
|
||||
|
||||
if [ $RESOURCE_COUNT -gt 1 ] || [ $VERB_COUNT -gt 3 ]; then
|
||||
echo "⚠️ ClusterRole '$CLUSTERROLE_NAME' may have more permissions than necessary for the least privilege principle"
|
||||
fi
|
||||
|
||||
# Success
|
||||
echo "✅ ClusterRole '$CLUSTERROLE_NAME' correctly allows get, watch, and list operations on pods"
|
||||
exit 0
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Validate that the ClusterRoleBinding 'read-pods' correctly associates the 'pod-reader' role with user 'jane'
|
||||
|
||||
CLUSTERROLEBINDING_NAME="read-pods"
|
||||
CLUSTERROLE_NAME="pod-reader"
|
||||
USER_NAME="jane"
|
||||
NAMESPACE="cluster-admin"
|
||||
|
||||
# Check if the ClusterRoleBinding exists
|
||||
kubectl get clusterrolebinding $CLUSTERROLEBINDING_NAME > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ClusterRoleBinding '$CLUSTERROLEBINDING_NAME' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the ClusterRoleBinding references the correct ClusterRole
|
||||
ROLE_REF=$(kubectl get clusterrolebinding $CLUSTERROLEBINDING_NAME -o jsonpath='{.roleRef.name}' 2>/dev/null)
|
||||
|
||||
if [ "$ROLE_REF" != "$CLUSTERROLE_NAME" ]; then
|
||||
echo "❌ ClusterRoleBinding '$CLUSTERROLEBINDING_NAME' references role '$ROLE_REF' instead of '$CLUSTERROLE_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the roleRef kind - should be ClusterRole
|
||||
ROLE_KIND=$(kubectl get clusterrolebinding $CLUSTERROLEBINDING_NAME -o jsonpath='{.roleRef.kind}' 2>/dev/null)
|
||||
|
||||
if [ "$ROLE_KIND" != "ClusterRole" ]; then
|
||||
echo "❌ ClusterRoleBinding '$CLUSTERROLEBINDING_NAME' references a '$ROLE_KIND' instead of a 'ClusterRole'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the ClusterRoleBinding has a subject for user 'jane'
|
||||
SUBJECTS=$(kubectl get clusterrolebinding $CLUSTERROLEBINDING_NAME -o json | jq -r '.subjects[] | select(.name=="jane" and .kind=="User")' 2>/dev/null)
|
||||
|
||||
if [ -z "$SUBJECTS" ]; then
|
||||
echo "❌ ClusterRoleBinding '$CLUSTERROLEBINDING_NAME' does not bind to user '$USER_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the namespace of the subject is specified as 'cluster-admin'
|
||||
SUBJECT_NS=$(kubectl get clusterrolebinding $CLUSTERROLEBINDING_NAME -o json | jq -r '.subjects[] | select(.name=="jane" and .kind=="User") | .namespace' 2>/dev/null)
|
||||
|
||||
if [ "$SUBJECT_NS" != "$NAMESPACE" ] && [ "$SUBJECT_NS" != "null" ]; then
|
||||
echo "❌ ClusterRoleBinding subject namespace is '$SUBJECT_NS' instead of '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Success
|
||||
echo "✅ ClusterRoleBinding '$CLUSTERROLEBINDING_NAME' correctly associates ClusterRole '$CLUSTERROLE_NAME' with user '$USER_NAME'"
|
||||
echo "✅ This binding grants Jane read-only access to pod resources across all namespaces"
|
||||
exit 0
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Validate that Helm is properly installed in the cluster
|
||||
|
||||
# Check if helm command is available
|
||||
command -v helm > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Helm command not found. Please install Helm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check helm version to ensure it's working
|
||||
HELM_VERSION=$(helm version --short 2>/dev/null)
|
||||
if [ $? -ne 0 ] || [ -z "$HELM_VERSION" ]; then
|
||||
echo "❌ Helm is installed but not working properly. Check Helm installation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Helm is correctly installed (version: $HELM_VERSION)"
|
||||
|
||||
# Check if the Bitnami repository is added
|
||||
BITNAMI_REPO=$(helm repo list 2>/dev/null | grep -i bitnami | wc -l)
|
||||
if [ $BITNAMI_REPO -eq 0 ]; then
|
||||
echo "⚠️ Bitnami repository is not added to Helm. Add it with: helm repo add bitnami https://charts.bitnami.com/bitnami"
|
||||
else
|
||||
echo "✅ Bitnami repository is properly configured"
|
||||
fi
|
||||
|
||||
# Check if helm can access the Kubernetes cluster
|
||||
HELM_LIST=$(helm list -A 2>/dev/null)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Helm cannot access the Kubernetes cluster. Check Kubernetes configuration."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Helm can successfully communicate with the Kubernetes cluster"
|
||||
exit 0
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Validate that the Bitnami repository is added to Helm
|
||||
|
||||
# Check if the Bitnami repository is added
|
||||
BITNAMI_REPO=$(helm repo list 2>/dev/null | grep -i bitnami)
|
||||
if [ $? -ne 0 ] || [ -z "$BITNAMI_REPO" ]; then
|
||||
echo "❌ Bitnami repository is not added to Helm"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the URL from the repo list
|
||||
REPO_URL=$(echo "$BITNAMI_REPO" | awk '{print $2}')
|
||||
|
||||
# Check if the URL is correct
|
||||
if [[ ! "$REPO_URL" =~ "charts.bitnami.com/bitnami" ]]; then
|
||||
echo "❌ Bitnami repository URL is incorrect: $REPO_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Bitnami repository is properly configured with URL: $REPO_URL"
|
||||
|
||||
# Check if repo is up to date
|
||||
LAST_UPDATE=$(helm repo list 2>/dev/null | grep -i bitnami | awk '{print $3}')
|
||||
if [ -n "$LAST_UPDATE" ]; then
|
||||
echo "ℹ️ Last repository update: $LAST_UPDATE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Validate that Bitnami Nginx is deployed with 2 replicas and LoadBalancer service type in namespace 'web'
|
||||
|
||||
NAMESPACE="web"
|
||||
RELEASE_NAME="nginx" # Default release name, can also check for any Nginx release
|
||||
|
||||
# Check if the namespace exists
|
||||
kubectl get namespace $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Namespace '$NAMESPACE' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if there's any nginx deployment from Helm in the namespace
|
||||
HELM_RELEASE=$(helm list -n $NAMESPACE 2>/dev/null | grep -i nginx | wc -l)
|
||||
if [ $HELM_RELEASE -eq 0 ]; then
|
||||
echo "❌ No Nginx Helm release found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Nginx Helm release found in namespace '$NAMESPACE'"
|
||||
|
||||
# Find the deployment name (may vary based on release name)
|
||||
DEPLOYMENT_NAME=$(kubectl get deployments -n $NAMESPACE -l "app.kubernetes.io/name=nginx" -o name 2>/dev/null || kubectl get deployments -n $NAMESPACE -l "app=nginx" -o name 2>/dev/null)
|
||||
|
||||
if [ -z "$DEPLOYMENT_NAME" ]; then
|
||||
echo "❌ Cannot find Nginx deployment in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEPLOYMENT_NAME=$(echo $DEPLOYMENT_NAME | sed 's|deployment.apps/||')
|
||||
echo "✅ Found Nginx deployment: $DEPLOYMENT_NAME"
|
||||
|
||||
# Check replicas count
|
||||
REPLICAS=$(kubectl get deployment $DEPLOYMENT_NAME -n $NAMESPACE -o jsonpath='{.spec.replicas}' 2>/dev/null)
|
||||
|
||||
if [ -z "$REPLICAS" ]; then
|
||||
echo "❌ Cannot get replica count for deployment '$DEPLOYMENT_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REPLICAS" -ne 2 ]; then
|
||||
echo "❌ Deployment '$DEPLOYMENT_NAME' has $REPLICAS replicas, but 2 were required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Deployment has correct number of replicas: $REPLICAS"
|
||||
|
||||
# Check service type
|
||||
SERVICE_NAME=$(kubectl get services -n $NAMESPACE -l "app.kubernetes.io/name=nginx" -o name 2>/dev/null || kubectl get services -n $NAMESPACE -l "app=nginx" -o name 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICE_NAME" ]; then
|
||||
echo "❌ Cannot find Nginx service in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME=$(echo $SERVICE_NAME | sed 's|service/||')
|
||||
SERVICE_TYPE=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.type}' 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICE_TYPE" ]; then
|
||||
echo "❌ Cannot get service type for service '$SERVICE_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SERVICE_TYPE" != "LoadBalancer" ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' is of type '$SERVICE_TYPE', but 'LoadBalancer' was required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service has correct type: $SERVICE_TYPE"
|
||||
|
||||
# Check if pods are running
|
||||
RUNNING_PODS=$(kubectl get pods -n $NAMESPACE -l "app.kubernetes.io/name=nginx" -o jsonpath='{.items[?(@.status.phase=="Running")].metadata.name}' 2>/dev/null | wc -w)
|
||||
|
||||
if [ -z "$RUNNING_PODS" ] || [ "$RUNNING_PODS" -eq 0 ]; then
|
||||
# Try another common label
|
||||
RUNNING_PODS=$(kubectl get pods -n $NAMESPACE -l "app=nginx" -o jsonpath='{.items[?(@.status.phase=="Running")].metadata.name}' 2>/dev/null | wc -w)
|
||||
fi
|
||||
|
||||
if [ -z "$RUNNING_PODS" ] || [ "$RUNNING_PODS" -lt "$REPLICAS" ]; then
|
||||
echo "❌ Not all Nginx pods are running (running: $RUNNING_PODS, expected: $REPLICAS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All $RUNNING_PODS Nginx pods are running"
|
||||
echo "✅ Bitnami Nginx has been successfully deployed with 2 replicas and LoadBalancer service"
|
||||
exit 0
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# Validate that the CustomResourceDefinition 'backups.data.example.com' has the correct API group and version
|
||||
|
||||
CRD_NAME="backups.data.example.com"
|
||||
EXPECTED_GROUP="data.example.com"
|
||||
EXPECTED_VERSION="v1alpha1"
|
||||
|
||||
# Check if the CRD exists
|
||||
kubectl get crd $CRD_NAME > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ CustomResourceDefinition '$CRD_NAME' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check API group
|
||||
GROUP=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.group}' 2>/dev/null)
|
||||
|
||||
if [ "$GROUP" != "$EXPECTED_GROUP" ]; then
|
||||
echo "❌ CRD has incorrect API group: '$GROUP', expected: '$EXPECTED_GROUP'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ CRD has correct API group: '$GROUP'"
|
||||
|
||||
# Check API version
|
||||
# The structure of versions can be different based on the Kubernetes version
|
||||
# First try the newer structure
|
||||
VERSIONS=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.versions[*].name}' 2>/dev/null)
|
||||
|
||||
# If that doesn't work, try the older structure
|
||||
if [ -z "$VERSIONS" ]; then
|
||||
VERSIONS=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.version}' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [[ ! $VERSIONS =~ $EXPECTED_VERSION ]]; then
|
||||
echo "❌ CRD versions ($VERSIONS) do not include expected version: '$EXPECTED_VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ CRD includes correct API version: '$EXPECTED_VERSION'"
|
||||
|
||||
# Check the resource type name (singular/plural)
|
||||
PLURAL=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.names.plural}' 2>/dev/null)
|
||||
SINGULAR=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.names.singular}' 2>/dev/null)
|
||||
KIND=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.names.kind}' 2>/dev/null)
|
||||
|
||||
if [ "$PLURAL" != "backups" ]; then
|
||||
echo "❌ CRD has incorrect plural name: '$PLURAL', expected: 'backups'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$KIND" != "Backup" ]; then
|
||||
echo "❌ CRD has incorrect kind: '$KIND', expected: 'Backup'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ CRD has correct names - plural: '$PLURAL', kind: '$KIND'"
|
||||
|
||||
# Check that the v1alpha1 version is served and stored
|
||||
SERVED=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.versions[?(@.name=="v1alpha1")].served}' 2>/dev/null)
|
||||
STORAGE=$(kubectl get crd $CRD_NAME -o jsonpath='{.spec.versions[?(@.name=="v1alpha1")].storage}' 2>/dev/null)
|
||||
|
||||
if [ "$SERVED" != "true" ] && [ -n "$SERVED" ]; then
|
||||
echo "⚠️ The version 'v1alpha1' might not be served (served: $SERVED)"
|
||||
fi
|
||||
|
||||
echo "✅ CustomResourceDefinition '$CRD_NAME' has correct API group '$EXPECTED_GROUP' and version '$EXPECTED_VERSION'"
|
||||
exit 0
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Validate that the CustomResourceDefinition 'backups.data.example.com' has required schema fields
|
||||
|
||||
CRD_NAME="backups.data.example.com"
|
||||
REQUIRED_FIELDS=("spec.source" "spec.destination")
|
||||
|
||||
# Check if the CRD exists
|
||||
kubectl get crd $CRD_NAME > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ CustomResourceDefinition '$CRD_NAME' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the full CRD definition for inspection
|
||||
CRD_JSON=$(kubectl get crd $CRD_NAME -o json)
|
||||
|
||||
# Check for schema validation in different Kubernetes API versions
|
||||
# First, try to get the schema for v1 API
|
||||
SCHEMA=$(echo "$CRD_JSON" | jq -r '.spec.versions[] | select(.name=="v1alpha1") | .schema.openAPIV3Schema // empty' 2>/dev/null)
|
||||
|
||||
# If that's empty, try the beta API versions
|
||||
if [ -z "$SCHEMA" ]; then
|
||||
SCHEMA=$(echo "$CRD_JSON" | jq -r '.spec.validation.openAPIV3Schema // empty' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$SCHEMA" ]; then
|
||||
echo "❌ No schema validation found in the CRD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Schema validation is defined for the CRD"
|
||||
|
||||
# Check for the required properties in the schema
|
||||
SCHEMA_PROPERTIES=$(echo "$CRD_JSON" | jq -r '.spec.versions[] | select(.name=="v1alpha1") | .schema.openAPIV3Schema.properties.spec.properties // empty' 2>/dev/null)
|
||||
|
||||
# If that's empty, try the beta API versions
|
||||
if [ -z "$SCHEMA_PROPERTIES" ]; then
|
||||
SCHEMA_PROPERTIES=$(echo "$CRD_JSON" | jq -r '.spec.validation.openAPIV3Schema.properties.spec.properties // empty' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$SCHEMA_PROPERTIES" ]; then
|
||||
echo "❌ Could not find 'spec' properties in the schema"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for each required field
|
||||
for field in "${REQUIRED_FIELDS[@]}"; do
|
||||
field_name=$(echo $field | cut -d'.' -f2)
|
||||
|
||||
# Check if the field is defined in properties
|
||||
FIELD_DEF=$(echo "$SCHEMA_PROPERTIES" | jq -r ".$field_name // empty" 2>/dev/null)
|
||||
|
||||
if [ -z "$FIELD_DEF" ]; then
|
||||
echo "❌ Required field '$field' is not defined in the schema"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Field '$field' is defined in the schema"
|
||||
|
||||
# Check if the field is a string type
|
||||
FIELD_TYPE=$(echo "$SCHEMA_PROPERTIES" | jq -r ".$field_name.type" 2>/dev/null)
|
||||
|
||||
if [ "$FIELD_TYPE" != "string" ]; then
|
||||
echo "⚠️ Field '$field' is of type '$FIELD_TYPE', not 'string' as expected"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if both fields are required
|
||||
REQUIRED_LIST=$(echo "$CRD_JSON" | jq -r '.spec.versions[] | select(.name=="v1alpha1") | .schema.openAPIV3Schema.properties.spec.required // empty' 2>/dev/null)
|
||||
|
||||
# If that's empty, try the beta API versions
|
||||
if [ -z "$REQUIRED_LIST" ]; then
|
||||
REQUIRED_LIST=$(echo "$CRD_JSON" | jq -r '.spec.validation.openAPIV3Schema.properties.spec.required // empty' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$REQUIRED_LIST" ]; then
|
||||
echo "⚠️ No required fields specified in the schema"
|
||||
else
|
||||
# Check if both source and destination are in the required list
|
||||
if echo "$REQUIRED_LIST" | jq -e 'index("source")' > /dev/null && echo "$REQUIRED_LIST" | jq -e 'index("destination")' > /dev/null; then
|
||||
echo "✅ Both 'source' and 'destination' are marked as required fields"
|
||||
else
|
||||
echo "⚠️ Not all required fields ('source', 'destination') are marked as required in the schema"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ CRD schema includes the required fields 'spec.source' and 'spec.destination'"
|
||||
exit 0
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the NetworkPolicy 'allow-traffic' exists in the 'networking' namespace
|
||||
if kubectl get networkpolicy allow-traffic -n networking &> /dev/null; then
|
||||
echo "Success: NetworkPolicy 'allow-traffic' exists in namespace 'networking'"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: NetworkPolicy 'allow-traffic' does not exist in namespace 'networking'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the Service 'internal-app' is of type ClusterIP in the 'networking' namespace
|
||||
SERVICE_TYPE=$(kubectl get service internal-app -n networking -o jsonpath='{.spec.type}' 2>/dev/null)
|
||||
|
||||
if [ "$SERVICE_TYPE" = "ClusterIP" ]; then
|
||||
echo "Success: Service 'internal-app' is of correct type (ClusterIP)"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: Service 'internal-app' is not of the correct type. Found: '$SERVICE_TYPE', Expected: 'ClusterIP'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Validate that the service 'public-web' in namespace 'networking' is of type NodePort with correct nodePort
|
||||
|
||||
NAMESPACE="networking"
|
||||
SERVICE_NAME="public-web"
|
||||
EXPECTED_TYPE="NodePort"
|
||||
EXPECTED_NODE_PORT=30080
|
||||
|
||||
# Check if the service exists
|
||||
kubectl get service $SERVICE_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check service type
|
||||
SERVICE_TYPE=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.type}' 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICE_TYPE" ]; then
|
||||
echo "❌ Cannot determine service type for '$SERVICE_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SERVICE_TYPE" != "$EXPECTED_TYPE" ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' is of type '$SERVICE_TYPE', not '$EXPECTED_TYPE' as required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service '$SERVICE_NAME' is correctly configured as type '$EXPECTED_TYPE'"
|
||||
|
||||
# Check nodePort value
|
||||
NODE_PORT=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null)
|
||||
|
||||
if [ -z "$NODE_PORT" ]; then
|
||||
echo "❌ NodePort is not configured for service '$SERVICE_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NODE_PORT" -ne "$EXPECTED_NODE_PORT" ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' uses nodePort $NODE_PORT, expected $EXPECTED_NODE_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service '$SERVICE_NAME' is correctly configured with nodePort: $NODE_PORT"
|
||||
exit 0
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
# Validate that the service 'public-web' in namespace 'networking' has the correct selector to target 'web-frontend' deployment pods
|
||||
|
||||
NAMESPACE="networking"
|
||||
SERVICE_NAME="public-web"
|
||||
DEPLOYMENT_NAME="web-frontend"
|
||||
|
||||
# Check if the service exists
|
||||
kubectl get service $SERVICE_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the deployment exists
|
||||
kubectl get deployment $DEPLOYMENT_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "⚠️ Deployment '$DEPLOYMENT_NAME' not found in namespace '$NAMESPACE', but will continue checking service selector"
|
||||
fi
|
||||
|
||||
# Get service selector
|
||||
SERVICE_SELECTOR=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o json | jq -r '.spec.selector' 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICE_SELECTOR" ] || [ "$SERVICE_SELECTOR" == "null" ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' does not have a selector"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔍 Service '$SERVICE_NAME' has selector: $SERVICE_SELECTOR"
|
||||
|
||||
# Check if deployment exists to compare selectors
|
||||
if kubectl get deployment $DEPLOYMENT_NAME -n $NAMESPACE > /dev/null 2>&1; then
|
||||
# Get deployment selector
|
||||
DEPLOYMENT_SELECTOR=$(kubectl get deployment $DEPLOYMENT_NAME -n $NAMESPACE -o json | jq -r '.spec.selector.matchLabels' 2>/dev/null)
|
||||
|
||||
if [ -z "$DEPLOYMENT_SELECTOR" ] || [ "$DEPLOYMENT_SELECTOR" == "null" ]; then
|
||||
echo "⚠️ Deployment '$DEPLOYMENT_NAME' does not have matchLabels in its selector"
|
||||
else
|
||||
echo "🔍 Deployment '$DEPLOYMENT_NAME' has selector: $DEPLOYMENT_SELECTOR"
|
||||
|
||||
# Check if service selector is a subset of deployment selector
|
||||
# This is a simplistic check and might not work for complex selectors
|
||||
MATCHES=true
|
||||
for key in $(echo "$SERVICE_SELECTOR" | jq -r 'keys[]'); do
|
||||
service_value=$(echo "$SERVICE_SELECTOR" | jq -r --arg k "$key" '.[$k]')
|
||||
deployment_value=$(echo "$DEPLOYMENT_SELECTOR" | jq -r --arg k "$key" '.[$k] // empty')
|
||||
|
||||
if [ "$service_value" != "$deployment_value" ]; then
|
||||
MATCHES=false
|
||||
echo "❌ Service selector key '$key' value '$service_value' does not match deployment value '$deployment_value'"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MATCHES" = true ]; then
|
||||
echo "✅ Service selector matches deployment selector"
|
||||
else
|
||||
echo "❌ Service selector does not match deployment selector"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if the service selects any pods
|
||||
SELECTOR_STRING=""
|
||||
for key in $(echo $SERVICE_SELECTOR | jq -r 'keys[]'); do
|
||||
value=$(echo $SERVICE_SELECTOR | jq -r --arg key "$key" '.[$key]')
|
||||
if [ -n "$SELECTOR_STRING" ]; then
|
||||
SELECTOR_STRING="$SELECTOR_STRING,"
|
||||
fi
|
||||
SELECTOR_STRING="${SELECTOR_STRING}${key}=${value}"
|
||||
done
|
||||
|
||||
SELECTED_PODS=$(kubectl get pods -n $NAMESPACE -l "$SELECTOR_STRING" -o name 2>/dev/null)
|
||||
|
||||
if [ -z "$SELECTED_PODS" ]; then
|
||||
echo "⚠️ No pods are currently selected by the service selector"
|
||||
else
|
||||
POD_COUNT=$(echo "$SELECTED_PODS" | wc -l)
|
||||
echo "✅ Service selector targets $POD_COUNT pods"
|
||||
fi
|
||||
|
||||
# Check if the service has endpoints
|
||||
ENDPOINTS=$(kubectl get endpoints $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.subsets[*].addresses}' 2>/dev/null)
|
||||
|
||||
if [ -z "$ENDPOINTS" ] || [ "$ENDPOINTS" == "[]" ]; then
|
||||
echo "⚠️ Service '$SERVICE_NAME' has no endpoints, which may indicate a selector problem"
|
||||
else
|
||||
echo "✅ Service '$SERVICE_NAME' has endpoints, indicating its selector is working"
|
||||
fi
|
||||
|
||||
echo "✅ Service '$SERVICE_NAME' has a selector that targets pods from the '$DEPLOYMENT_NAME' deployment"
|
||||
exit 0
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Validate that the service 'public-web' in namespace 'networking' has correct port configurations
|
||||
|
||||
NAMESPACE="networking"
|
||||
SERVICE_NAME="public-web"
|
||||
EXPECTED_PORT=80
|
||||
EXPECTED_TARGET_PORT=8080
|
||||
EXPECTED_NODE_PORT=30080
|
||||
|
||||
# Check if the service exists
|
||||
kubectl get service $SERVICE_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check service port configuration
|
||||
SERVICE_PORT=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.ports[0].port}' 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICE_PORT" ]; then
|
||||
echo "❌ Cannot determine service port for '$SERVICE_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SERVICE_PORT" != "$EXPECTED_PORT" ]; then
|
||||
echo "❌ Service '$SERVICE_NAME' exposes port $SERVICE_PORT, not port $EXPECTED_PORT as required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service '$SERVICE_NAME' correctly exposes port $SERVICE_PORT"
|
||||
|
||||
# Check target port configuration
|
||||
TARGET_PORT=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.ports[0].targetPort}' 2>/dev/null)
|
||||
|
||||
if [ -z "$TARGET_PORT" ]; then
|
||||
echo "❌ Target port is not specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TARGET_PORT" != "$EXPECTED_TARGET_PORT" ]; then
|
||||
echo "❌ Service has incorrect target port: $TARGET_PORT, expected: $EXPECTED_TARGET_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service has correct target port: $TARGET_PORT"
|
||||
|
||||
# Check node port configuration
|
||||
NODE_PORT=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null)
|
||||
|
||||
if [ -z "$NODE_PORT" ]; then
|
||||
echo "❌ NodePort is not specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NODE_PORT" != "$EXPECTED_NODE_PORT" ]; then
|
||||
echo "❌ Service has incorrect node port: $NODE_PORT, expected: $EXPECTED_NODE_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Service has correct node port: $NODE_PORT"
|
||||
|
||||
# Check if the target port exists in the pods
|
||||
SERVICE_SELECTOR=$(kubectl get service $SERVICE_NAME -n $NAMESPACE -o json | jq -r '.spec.selector' 2>/dev/null)
|
||||
|
||||
if [ -n "$SERVICE_SELECTOR" ] && [ "$SERVICE_SELECTOR" != "null" ]; then
|
||||
# Format selector for kubectl label selector
|
||||
SELECTOR_STRING=""
|
||||
for key in $(echo $SERVICE_SELECTOR | jq -r 'keys[]'); do
|
||||
value=$(echo $SERVICE_SELECTOR | jq -r --arg key "$key" '.[$key]')
|
||||
if [ -n "$SELECTOR_STRING" ]; then
|
||||
SELECTOR_STRING="$SELECTOR_STRING,"
|
||||
fi
|
||||
SELECTOR_STRING="${SELECTOR_STRING}${key}=${value}"
|
||||
done
|
||||
|
||||
# Get a pod that matches the selector
|
||||
POD_NAME=$(kubectl get pods -n $NAMESPACE -l "$SELECTOR_STRING" -o name 2>/dev/null | head -n 1 | cut -d '/' -f 2)
|
||||
|
||||
if [ -n "$POD_NAME" ]; then
|
||||
# Check if the target port matches any container port in the pod
|
||||
CONTAINER_PORTS=$(kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[*].ports[*].containerPort}' 2>/dev/null)
|
||||
|
||||
if [[ $CONTAINER_PORTS == *"$TARGET_PORT"* ]]; then
|
||||
echo "✅ Target port $TARGET_PORT matches container port in selected pods"
|
||||
else
|
||||
echo "⚠️ Target port $TARGET_PORT does not match any container port in pod $POD_NAME (ports: $CONTAINER_PORTS)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No pods found matching service selector, cannot verify container ports"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Service '$SERVICE_NAME' has correct port configuration (port: $SERVICE_PORT, targetPort: $TARGET_PORT, nodePort: $NODE_PORT)"
|
||||
exit 0
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Validate that the Ingress 'api-ingress' is created in namespace 'networking'
|
||||
|
||||
NAMESPACE="networking"
|
||||
INGRESS_NAME="api-ingress"
|
||||
|
||||
# Check if the ingress exists
|
||||
kubectl get ingress $INGRESS_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Ingress '$INGRESS_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the API version of the Ingress
|
||||
API_VERSION=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.apiVersion}' 2>/dev/null)
|
||||
|
||||
echo "ℹ️ Ingress '$INGRESS_NAME' exists in namespace '$NAMESPACE' (API version: $API_VERSION)"
|
||||
|
||||
# Check for any ingressClassName or annotations for ingress controller class
|
||||
INGRESS_CLASS=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)
|
||||
INGRESS_CLASS_ANNOTATION=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.metadata.annotations.kubernetes\.io/ingress\.class}' 2>/dev/null)
|
||||
|
||||
if [ -n "$INGRESS_CLASS" ]; then
|
||||
echo "ℹ️ Ingress is using ingressClassName: $INGRESS_CLASS"
|
||||
elif [ -n "$INGRESS_CLASS_ANNOTATION" ]; then
|
||||
echo "ℹ️ Ingress is using annotation for ingress class: $INGRESS_CLASS_ANNOTATION"
|
||||
fi
|
||||
|
||||
echo "✅ Ingress '$INGRESS_NAME' is successfully created in namespace '$NAMESPACE'"
|
||||
exit 0
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Validate that the Ingress 'api-ingress' in namespace 'networking' has the correct host 'api.example.com'
|
||||
|
||||
NAMESPACE="networking"
|
||||
INGRESS_NAME="api-ingress"
|
||||
EXPECTED_HOST="api.example.com"
|
||||
|
||||
# Check if the ingress exists
|
||||
kubectl get ingress $INGRESS_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Ingress '$INGRESS_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Try different structures for different Kubernetes API versions
|
||||
# For networking.k8s.io/v1 API
|
||||
HOSTS_V1=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.spec.rules[*].host}' 2>/dev/null)
|
||||
|
||||
if [ -z "$HOSTS_V1" ]; then
|
||||
# For extensions/v1beta1 or networking.k8s.io/v1beta1 API
|
||||
HOSTS_BETA=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.spec.rules[*].host}' 2>/dev/null)
|
||||
|
||||
if [ -n "$HOSTS_BETA" ]; then
|
||||
HOSTS=$HOSTS_BETA
|
||||
else
|
||||
echo "❌ No hosts found in Ingress rules"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
HOSTS=$HOSTS_V1
|
||||
fi
|
||||
|
||||
# Check if expected host is in the list of hosts
|
||||
FOUND=false
|
||||
for HOST in $HOSTS; do
|
||||
if [ "$HOST" == "$EXPECTED_HOST" ]; then
|
||||
FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FOUND" = true ]; then
|
||||
echo "✅ Ingress '$INGRESS_NAME' has the correct host: '$EXPECTED_HOST'"
|
||||
else
|
||||
echo "❌ Ingress '$INGRESS_NAME' does not have the expected host '$EXPECTED_HOST'. Found hosts: $HOSTS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if there are TLS settings for the host
|
||||
TLS_HOSTS=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.spec.tls[*].hosts[*]}' 2>/dev/null)
|
||||
|
||||
if [ -n "$TLS_HOSTS" ]; then
|
||||
# Check if expected host is in TLS hosts
|
||||
TLS_FOUND=false
|
||||
for TLS_HOST in $TLS_HOSTS; do
|
||||
if [ "$TLS_HOST" == "$EXPECTED_HOST" ]; then
|
||||
TLS_FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$TLS_FOUND" = true ]; then
|
||||
echo "ℹ️ TLS is configured for host '$EXPECTED_HOST'"
|
||||
else
|
||||
echo "ℹ️ TLS is configured but not for host '$EXPECTED_HOST'"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Ingress '$INGRESS_NAME' is correctly configured with host '$EXPECTED_HOST'"
|
||||
exit 0
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# Validate that the Ingress 'api-ingress' in namespace 'networking' routes traffic to service 'api-service' on port 80
|
||||
|
||||
NAMESPACE="networking"
|
||||
INGRESS_NAME="api-ingress"
|
||||
EXPECTED_HOST="api.example.com"
|
||||
EXPECTED_SERVICE="api-service"
|
||||
EXPECTED_PORT=80
|
||||
|
||||
# Check if the ingress exists
|
||||
kubectl get ingress $INGRESS_NAME -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Ingress '$INGRESS_NAME' not found in namespace '$NAMESPACE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the service exists
|
||||
kubectl get service $EXPECTED_SERVICE -n $NAMESPACE > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "⚠️ Service '$EXPECTED_SERVICE' not found in namespace '$NAMESPACE'"
|
||||
# Continue with validation as the service might be created later
|
||||
fi
|
||||
|
||||
# Get API version to handle different Ingress structures
|
||||
API_VERSION=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o jsonpath='{.apiVersion}' 2>/dev/null)
|
||||
echo "ℹ️ Ingress API version: $API_VERSION"
|
||||
|
||||
# Handle different API versions (v1 vs v1beta1)
|
||||
if [[ "$API_VERSION" == "networking.k8s.io/v1" ]]; then
|
||||
# For v1 API
|
||||
RULE_INDEX=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --arg host "$EXPECTED_HOST" '.spec.rules | map(.host == $host) | index(true) // empty')
|
||||
|
||||
if [ -z "$RULE_INDEX" ]; then
|
||||
echo "❌ No rule found for host '$EXPECTED_HOST'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check path-based rules
|
||||
PATHS=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" '.spec.rules[$idx].http.paths[].path // "/"')
|
||||
|
||||
for PATH in $PATHS; do
|
||||
# For each path, check the backend service
|
||||
BACKEND_SERVICE_NAME=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" --arg path "$PATH" '.spec.rules[$idx].http.paths[] | select(.path == $path or (.path == null and $path == "/")).backend.service.name // empty')
|
||||
BACKEND_SERVICE_PORT=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" --arg path "$PATH" '.spec.rules[$idx].http.paths[] | select(.path == $path or (.path == null and $path == "/")).backend.service.port.number // empty')
|
||||
|
||||
# If port is not a number, try getting it as a name
|
||||
if [ -z "$BACKEND_SERVICE_PORT" ]; then
|
||||
BACKEND_SERVICE_PORT=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" --arg path "$PATH" '.spec.rules[$idx].http.paths[] | select(.path == $path or (.path == null and $path == "/")).backend.service.port.name // empty')
|
||||
fi
|
||||
|
||||
echo "🔍 Path '$PATH' routes to service: $BACKEND_SERVICE_NAME, port: $BACKEND_SERVICE_PORT"
|
||||
|
||||
if [ "$BACKEND_SERVICE_NAME" = "$EXPECTED_SERVICE" ]; then
|
||||
# Check port
|
||||
if [ "$BACKEND_SERVICE_PORT" = "$EXPECTED_PORT" ] || [ "$BACKEND_SERVICE_PORT" = "http" ]; then
|
||||
echo "✅ Found correct backend service and port for path '$PATH'"
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Service name matches but port is different: expected $EXPECTED_PORT, got $BACKEND_SERVICE_PORT"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
# For v1beta1 or extensions/v1beta1 API
|
||||
RULE_INDEX=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --arg host "$EXPECTED_HOST" '.spec.rules | map(.host == $host) | index(true) // empty')
|
||||
|
||||
if [ -z "$RULE_INDEX" ]; then
|
||||
echo "❌ No rule found for host '$EXPECTED_HOST'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check path-based rules
|
||||
PATHS=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" '.spec.rules[$idx].http.paths[].path // "/"')
|
||||
|
||||
for PATH in $PATHS; do
|
||||
# For each path, check the backend service
|
||||
BACKEND_SERVICE_NAME=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" --arg path "$PATH" '.spec.rules[$idx].http.paths[] | select(.path == $path or (.path == null and $path == "/")).backend.serviceName // empty')
|
||||
BACKEND_SERVICE_PORT=$(kubectl get ingress $INGRESS_NAME -n $NAMESPACE -o json | jq -r --argjson idx "$RULE_INDEX" --arg path "$PATH" '.spec.rules[$idx].http.paths[] | select(.path == $path or (.path == null and $path == "/")).backend.servicePort // empty')
|
||||
|
||||
echo "🔍 Path '$PATH' routes to service: $BACKEND_SERVICE_NAME, port: $BACKEND_SERVICE_PORT"
|
||||
|
||||
if [ "$BACKEND_SERVICE_NAME" = "$EXPECTED_SERVICE" ]; then
|
||||
# Check port - can be number or name
|
||||
if [ "$BACKEND_SERVICE_PORT" = "$EXPECTED_PORT" ] || [ "$BACKEND_SERVICE_PORT" = "http" ]; then
|
||||
echo "✅ Found correct backend service and port for path '$PATH'"
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Service name matches but port is different: expected $EXPECTED_PORT, got $BACKEND_SERVICE_PORT"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# If we get here, we didn't find the expected backend
|
||||
echo "❌ Ingress does not route traffic to '$EXPECTED_SERVICE' on port $EXPECTED_PORT for host '$EXPECTED_HOST'"
|
||||
exit 1
|
||||
@@ -0,0 +1,11 @@
|
||||
#bin/bash
|
||||
|
||||
# Validate namespace if present then return 0 else return 1
|
||||
kubectl get namespace dev
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Namespace dev is present"
|
||||
exit 0
|
||||
else
|
||||
echo "Namespace dev is not present"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the deployment 'nginx-deployment' exists in the 'dev' namespace
|
||||
if kubectl get deployment nginx-deployment -n dev &> /dev/null; then
|
||||
echo "Success: Deployment 'nginx-deployment' exists in namespace 'dev'"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: Deployment 'nginx-deployment' does not exist in namespace 'dev'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate if the deployment 'nginx-deployment' in namespace 'dev' is using the correct image (nginx:latest)
|
||||
IMAGE=$(kubectl get deployment nginx-deployment -n dev -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)
|
||||
|
||||
if [ "$IMAGE" = "nginx:latest" ]; then
|
||||
echo "Success: Deployment 'nginx-deployment' is using the correct image 'nginx:latest'"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: Deployment 'nginx-deployment' is not using the correct image. Found: '$IMAGE', Expected: 'nginx:latest'"
|
||||
exit 1
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user