From 243376e7857b24e6c7d49a26ab1a3d13a120ac79 Mon Sep 17 00:00:00 2001 From: Begild Date: Sun, 5 Jan 2025 09:59:40 +0800 Subject: [PATCH] first commit --- .gitignore | 24 ++++ index.html | 24 ++++ public/favicon.png | Bin 0 -> 3838 bytes server.py | 108 ++++++++++++++++ src/components/LicenseBar.js | 113 +++++++++++++++++ src/components/QueueDisplay.js | 33 +++++ src/main.js | 41 +++++++ src/services/licenseService.js | 49 ++++++++ src/services/queueService.js | 17 +++ src/utils/dataTransformer.js | 26 ++++ src/utils/timeConverter.js | 10 ++ src/utils/timeFormatter.js | 12 ++ style.css | 217 +++++++++++++++++++++++++++++++++ 13 files changed, 674 insertions(+) create mode 100755 .gitignore create mode 100755 index.html create mode 100755 public/favicon.png create mode 100755 server.py create mode 100755 src/components/LicenseBar.js create mode 100755 src/components/QueueDisplay.js create mode 100755 src/main.js create mode 100755 src/services/licenseService.js create mode 100755 src/services/queueService.js create mode 100755 src/utils/dataTransformer.js create mode 100755 src/utils/timeConverter.js create mode 100755 src/utils/timeFormatter.js create mode 100755 style.css diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/index.html b/index.html new file mode 100755 index 0000000..579ea55 --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + + + SStar VCast License Usage Dashboard + + + + + +
+

SStar VCast License Usage Dashboard

+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/public/favicon.png b/public/favicon.png new file mode 100755 index 0000000000000000000000000000000000000000..46012adf1fe43b068c2d744a5e301c7ddc8f7fb2 GIT binary patch literal 3838 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 Not Found

The page you are looking for does not exist.

") + + def send_file(self, file_path) -> bool: + file_path =str(Path(os.path.abspath(__file__)).parent.joinpath(file_path)) + print(f"clinet request: [{file_path}]") + if not os.path.isfile(file_path): + return False + self.send_response(200) + mime_type = self.get_mime_type(file_path) + self.send_header('Content-type', mime_type) + if mime_type.startswith('text/'): + self.send_header('Content-Disposition', f'inline; filename={os.path.basename(file_path)}') + else: + self.send_header('Content-Disposition', f'attachment; filename={os.path.basename(file_path)}') + self.end_headers() + with open(file_path, 'rb') as f: + self.wfile.write(f.read()) + return True + + def get_mime_type(self, file_path): + ext = os.path.splitext(file_path)[1] + if ext == '.txt': + return 'text/plain' + elif ext == '.js': + return 'application/javascript' + elif ext == '.html': + return 'text/html' + elif ext == '.css': + return 'text/css' + # 添加更多 MIME 类型根据需要 + return 'application/octet-stream' # 默认类型 +def run(server_class=HTTPServer, handler_class=SimpleHTTPRequestHandler, port=8088): + server_address = ('0.0.0.0', port) + httpd = server_class(server_address, handler_class) + print(f'Starting server on {server_address[0]}:{server_address[1]} ...') + httpd.serve_forever() + +if __name__ == "__main__": + if len(os.sys.argv) > 1: + run(port=int(os.sys.argv[1])) + else: + run() diff --git a/src/components/LicenseBar.js b/src/components/LicenseBar.js new file mode 100755 index 0000000..69e999d --- /dev/null +++ b/src/components/LicenseBar.js @@ -0,0 +1,113 @@ +export function createLicenseBar(license, max_duration) { + const container = document.createElement('div'); + container.className = 'license-bar'; + // License name and slot info + const nameSection = document.createElement('div'); + nameSection.className = 'license-info'; + nameSection.innerHTML = ` +
${license.licenseName}
+
${license.usedSlots}/${license.totalSlots} slots
+ `; + + // Usage bars container + const barsContainer = document.createElement('div'); + barsContainer.className = 'usage-bars'; + + // Create bars for each slot + for (let i = 0; i < license.totalSlots; i++) { + const usage = license.usageDetails[i]; + const bar = document.createElement('div'); + bar.className = 'bar-container'; + + const barContainer = document.createElement('div'); + barContainer.className = 'bar-wrapper'; + + + if (!usage) { + const bar = document.createElement('div'); + bar.className = 'bar free'; + const label = document.createElement('div'); + label.className = 'bar-label'; + label.textContent = 'AVAILABLE'; + bar.appendChild(label); + bar.style.width = '100%'; + requestAnimationFrame(() => { + label.style.transform = 'translate(0%)'; + }); + } else { + const bar = document.createElement('div'); + bar.className = 'bar'; + const width = usage.duration / max_duration * 100; + // 创建标签容器 + const labelContainer = document.createElement('div'); + labelContainer.className = 'label-container'; + + // 用户信息标签 + const userLabel = document.createElement('div'); + userLabel.className = 'user-label'; + userLabel.textContent = usage.userName; + + // 时长标签 + const durationLabel = document.createElement('div'); + durationLabel.className = 'duration-label'; + durationLabel.textContent = `${usage.duration.toFixed(1)}h`; + + if (width < 50) { + labelContainer.appendChild(durationLabel); + bar.appendChild(userLabel); + } else { + bar.appendChild(userLabel); + labelContainer.appendChild(durationLabel); + } + // const label = document.createElement('div'); + // label.className = 'bar-label'; + // label.textContent = `${usage.userName} (${usage.duration}h)`; + // // const hostLabel = document.createElement('div'); + // // hostLabel.className = 'user_info-label'; + // // hostLabel.textContent = ``; + // // // hostLabel.textContent = `${usage.userName}@${usage.hostName}`; + + // // barElement.appendChild(hostLabel); + // barElement.appendChild(label); + // 将标签添加到bar容器中 + barContainer.appendChild(labelContainer); + barContainer.appendChild(bar); + + requestAnimationFrame(() => { + const user_width = userLabel.offsetWidth; + const duration_width = labelContainer.offsetWidth + const duration_per = duration_width / bar.offsetWidth; + bar.style.width = `${width}%`; + + // 根据宽度决定标签位置 + if (width + duration_per >= 100) { // 如果超出bar的范围则放在里面 + labelContainer.classList.add('inside'); + } else { + labelContainer.classList.remove('outside'); + labelContainer.classList.add('outside'); + // const per = user_width * 100 / bar.offsetWidth; + const rect1 = userLabel.getBoundingClientRect(); + const rect2 = durationLabel.getBoundingClientRect(); + + // 检查重叠 + const isOverlapping = !(rect1.right < rect2.left || + rect1.left > rect2.right); + + if (isOverlapping) { + // 如果重叠,将 element2 移动到 element1 的右侧 + console.log("trace", rect1.right) + labelContainer.style.left = `${userLabel.offsetLeft + userLabel.offsetWidth + 10}px`; // 10px 的间距 + } else { + labelContainer.style.left = `${width + max_duration / 200}%` + } + } + }); + } + + barsContainer.appendChild(barContainer); + } + + container.appendChild(nameSection); + container.appendChild(barsContainer); + return container; +} \ No newline at end of file diff --git a/src/components/QueueDisplay.js b/src/components/QueueDisplay.js new file mode 100755 index 0000000..d8824d5 --- /dev/null +++ b/src/components/QueueDisplay.js @@ -0,0 +1,33 @@ +export function createQueueDisplay(queueData) { + const container = document.createElement('div'); + container.className = 'queue-container'; + + const title = document.createElement('h2'); + title.textContent = '排队情况'; + title.className = 'queue-title'; + container.appendChild(title); + + if (!queueData || queueData.length === 0) { + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'queue-empty'; + emptyMessage.textContent = '当前无人排队'; + container.appendChild(emptyMessage); + return container; + } + + const queueList = document.createElement('div'); + queueList.className = 'queue-list'; + + queueData.forEach(item => { + const queueItem = document.createElement('div'); + queueItem.className = 'queue-item'; + queueItem.innerHTML = ` + ${item.userName} + ${item.waitTime} + `; + queueList.appendChild(queueItem); + }); + + container.appendChild(queueList); + return container; +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100755 index 0000000..2647aa5 --- /dev/null +++ b/src/main.js @@ -0,0 +1,41 @@ +import { fetchLicenseData } from './services/licenseService.js'; +import { fetchQueueData } from './services/queueService.js'; +import { createLicenseBar } from './components/LicenseBar.js'; +import { createQueueDisplay } from './components/QueueDisplay.js'; +import { formatDateTime } from './utils/timeFormatter.js'; + +function updateLastRefreshTime() { + const updateTimeElement = document.getElementById('update-time'); + updateTimeElement.textContent = `Last Update: ${formatDateTime(new Date())}`; +} + +async function renderChart() { + const [licenseData, queueData] = await Promise.all([ + fetchLicenseData(), + fetchQueueData() + ]); + + const max_duration = licenseData.reduce((max, license) => { + return Math.max(max, license.maxDuration); + }, 0); + // 渲染许可证使用情况 + const chartContainer = document.getElementById('chart-container'); + chartContainer.innerHTML = ''; + licenseData.forEach(license => { + const licenseBar = createLicenseBar(license, max_duration); + chartContainer.appendChild(licenseBar); + }); + + // 渲染排队情况 + const queueContainer = document.getElementById('queue-container'); + queueContainer.innerHTML = ''; + queueContainer.appendChild(createQueueDisplay(queueData)); + + updateLastRefreshTime(); +} + +// 初始渲染 +renderChart(); + +// 每1分钟更新一次数据 +// setInterval(renderChart, 10000); \ No newline at end of file diff --git a/src/services/licenseService.js b/src/services/licenseService.js new file mode 100755 index 0000000..3400754 --- /dev/null +++ b/src/services/licenseService.js @@ -0,0 +1,49 @@ +import { transformLicenseData } from '../utils/dataTransformer.js'; + +// 测试用的模拟数据 +const mockData = { + "27003@szmis03": { + "name": "27003@szmis03", + "total": 2, + "used": 2, + "used_info": [ + { + "user": "ekko.bao", + "host": "szl3bc1808", + "login_time": "3.4h" + }, + { + "user": "zzc", + "host": "szl3bc1808", + "login_time": "6.9h" + } + ] + }, + "27003@szmis04": { + "name": "27003@szmis04", + "total": 2, + "used": 0, + "used_info": [] + }, + "27003@szmis10": { + "name": "27003@szmis10", + "total": 2, + "used": 1, + "used_info": [{ + "user": "zzc33333", + "host": "szl3bc1808", + "login_time": "58.9h" + }] + } +}; + +export async function fetchLicenseData() { + try { + const response = await fetch('/vcast/lic_use_info'); + const data = await response.json(); + return transformLicenseData(data); + } catch (error) { + console.error('Error fetching license data:', error); + return transformLicenseData(mockData); + } +} \ No newline at end of file diff --git a/src/services/queueService.js b/src/services/queueService.js new file mode 100755 index 0000000..a29350a --- /dev/null +++ b/src/services/queueService.js @@ -0,0 +1,17 @@ +// 测试用的模拟队列数据 +const mockQueueData = [ + { userName: "Alice Chen", waitTime: "15分钟" }, + { userName: "Bob Wang", waitTime: "8分钟" }, + { userName: "Charlie Liu", waitTime: "3分钟" } +]; + +export async function fetchQueueData() { + try { + const response = await fetch('/vcast/queue_info'); + const data = await response.json(); + return data; + } catch (error) { + console.error('Error fetching queue data:', error); + return mockQueueData; + } +} \ No newline at end of file diff --git a/src/utils/dataTransformer.js b/src/utils/dataTransformer.js new file mode 100755 index 0000000..d1b3acd --- /dev/null +++ b/src/utils/dataTransformer.js @@ -0,0 +1,26 @@ +import { convertTimeToMinutes } from './timeConverter.js'; + +// 计算许可证的最大使用时长 +function getMaxDuration(license) { + if (!license.used_info.length) return 0; + return Math.max(...license.used_info.map(info => + convertTimeToMinutes(info.login_time) + )); +} + +export function transformLicenseData(data) { + // 转换并排序数据 + return Object.values(data) + .map(license => ({ + licenseName: license.name, + totalSlots: license.total, + usedSlots: license.used, + maxDuration: getMaxDuration(license), + usageDetails: license.used_info.map(info => ({ + userName: info.user, + hostName: info.host, + duration: convertTimeToMinutes(info.login_time) + })) + })) + .sort((a, b) => b.maxDuration - a.maxDuration); // 按最大使用时长降序排序 +} \ No newline at end of file diff --git a/src/utils/timeConverter.js b/src/utils/timeConverter.js new file mode 100755 index 0000000..15a38ab --- /dev/null +++ b/src/utils/timeConverter.js @@ -0,0 +1,10 @@ +// 时间转换工具 +export function convertTimeToMinutes(timeStr) { + const match = timeStr.match(/(\d+\.?\d*)([hm])/); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2]; + + return unit === 'h' ? value : value / 60.0; +} \ No newline at end of file diff --git a/src/utils/timeFormatter.js b/src/utils/timeFormatter.js new file mode 100755 index 0000000..b04fc50 --- /dev/null +++ b/src/utils/timeFormatter.js @@ -0,0 +1,12 @@ +// Format date to locale string with seconds +export function formatDateTime(date) { + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); +} \ No newline at end of file diff --git a/style.css b/style.css new file mode 100755 index 0000000..62192f4 --- /dev/null +++ b/style.css @@ -0,0 +1,217 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 20px; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +h1 { + text-align: center; + color: #333; + margin-bottom: 30px; +} + +.license-bar { + display: flex; + margin-bottom: 15px; + background-color: white; + border-radius: 4px; + padding: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.license-info { + margin-left: 10px; + display: flex; + flex-direction: column; + margin-right: 10px; +} + +.license-name { + margin-top: 20px; + font-size: 16px; + font-weight: bold; + color: #2c3e50; + display: flex; + align-items: center; +} + +.slot-info { + font-size: 12px; + color: #666; +} + +.usage-bars { + display: flex; + flex-direction: column; + height: 60px; + gap: 6px; + flex-grow: 1; + margin-right: 5px; + margin-top: 5px; + margin-bottom: 5px; +} + +.bar-wrapper { + position: relative; + height: 32px; + background-color: #f8f9fa; + border-radius: 4px; + overflow: visible; + /* 允许标签显示在外部 */ +} + +.bar-container { + flex: 1; + background-color: #eee; + border-radius: 4px; + overflow: hidden; +} + +.bar { + width: 100%; + height: 100%; + background-color: #4caf50; + display: flex; + transition: width 1s ease; + white-space: nowrap; + align-items: center; + justify-content: space-between; + border-radius: 4px; +} + +.bar.free { + background-color: transparent; + border: 2px dashed #4caf50; + box-sizing: border-box; +} + +.label-container { + position: absolute; + display: flex; + align-items: center; + gap: 8px; + height: 100%; + transition: all 0.3s ease; + pointer-events: none; + white-space: nowrap; +} + +.label-container.outside { + color: #2c3e50; +} + +.label-container.inside { + right: 8px; + color: #fff; +} + +.bar-label { + color: #212121; + font-size: 16px; + white-space: nowrap; + margin: 10px; + margin-left: 10%; +} + +.user-label { + margin: 10px; + color: rgba(33, 33, 33, 0.7); + font-size: 14px; + display: inline; +} + +.duration-label { + font-size: 12px; + opacity: 0.9; + display: inline; +} + +.free .bar-label { + color: #4caf50; + width: 100%; + text-align: center; + transform: translateY(100%); + transition: transform 1s ease; +} + +.update-time { + text-align: center; + color: #666; + margin-top: 20px; + font-size: 14px; +} + + +.dashboard-layout { + display: flex; + gap: 20px; +} + +.chart-section { + flex: 3; + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; +} + +.queue-section { + flex: 1; + min-width: 250px; +} + +.queue-container { + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; +} + +.queue-title { + font-size: 1.2em; + color: #2c3e50; + margin: 0 0 15px 0; + padding-bottom: 10px; + border-bottom: 1px solid #e1e4e8; +} + +.queue-empty { + color: #666; + text-align: center; + font-style: italic; + margin: 20px 0; +} + +.queue-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.queue-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.queue-user { + font-weight: 500; + color: #2c3e50; +} + +.queue-time { + color: #666; + font-size: 0.9em; +} \ No newline at end of file