Files
ewoooc/templates/cicd_dashboard.html
OoO 14d645b4b1
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s
fix: 清理系統頁舊色碼殘留
2026-05-17 21:29:36 +08:00

1213 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "ewoooc_base.html" %}
{% block title %}CI/CD Dashboard - EwoooC{% endblock %}
{% block extra_head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
{% endblock %}
{% block extra_css %}
<style>
.cicd-page {
--success-color: var(--momo-success);
--danger-color: var(--momo-danger);
--warning-color: var(--momo-warning);
--info-color: var(--momo-info);
--uat-color: var(--momo-info);
--prod-color: var(--momo-danger);
color: var(--momo-text-primary);
font-family: var(--momo-font-family);
}
.cicd-page * {
letter-spacing: 0;
}
.dashboard-header {
background: var(--momo-dot-grid), var(--momo-bg-surface);
border: 1px solid var(--momo-border-strong);
border-radius: var(--momo-radius-md);
padding: 1.25rem;
margin-bottom: 2rem;
box-shadow: var(--momo-shadow-soft);
}
.dashboard-header h1 {
margin: 0;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: var(--momo-text-headline);
font-weight: 800;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
animation: pulse 2s infinite;
}
.status-indicator.healthy { background-color: var(--success-color); }
.status-indicator.unhealthy { background-color: var(--danger-color); }
.status-indicator.warning { background-color: var(--warning-color); }
.status-indicator.running { background-color: var(--info-color); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.card {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
backdrop-filter: none;
transition: transform 0.3s, box-shadow 0.3s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--momo-shadow-soft);
}
.card-header {
background: transparent;
border-bottom: 1px solid var(--momo-border-light);
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-weight: 800;
}
/* Pipeline Flow */
.pipeline-flow {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 2rem;
flex-wrap: wrap;
}
.pipeline-stage {
display: flex;
flex-direction: column;
align-items: center;
min-width: 120px;
}
.stage-box {
width: 100px;
height: 100px;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: 600;
transition: all 0.3s;
cursor: pointer;
}
.stage-box:hover {
transform: scale(1.05);
}
.stage-box.success { background: var(--momo-success-bg); color: var(--momo-success-text); border: 1px solid var(--momo-success-border); }
.stage-box.failed { background: var(--momo-danger-bg); color: var(--momo-danger-text); border: 1px solid var(--momo-danger-border); }
.stage-box.running { background: var(--momo-info-bg); color: var(--momo-info-text); border: 1px solid var(--momo-info-border); animation: glow 1.5s infinite; }
.stage-box.pending { background: var(--momo-bg-subtle); color: var(--momo-text-secondary); border: 1px solid var(--momo-border-light); }
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); }
50% { box-shadow: 0 0 20px rgba(0, 123, 255, 0.8); }
}
.stage-icon {
font-size: 1.8rem;
margin-bottom: 0.25rem;
}
.stage-name {
font-size: 0.85rem;
text-transform: uppercase;
}
.stage-duration {
font-size: 0.75rem;
opacity: 0.8;
margin-top: 0.5rem;
}
.pipeline-arrow {
width: 40px;
height: 2px;
background: var(--momo-border-strong);
position: relative;
}
.pipeline-arrow::after {
content: '';
position: absolute;
right: -5px;
top: -4px;
border: 5px solid transparent;
border-left-color: var(--momo-border-strong);
}
/* Environment Cards */
.env-card {
border-left: 4px solid;
transition: all 0.3s;
}
.env-card.uat { border-left-color: var(--uat-color); }
.env-card.prod { border-left-color: var(--prod-color); }
.env-icon {
font-size: 2rem;
margin-right: 1rem;
}
.env-details {
font-size: 0.9rem;
}
.pod-status {
display: flex;
align-items: center;
padding: 0.5rem;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
margin-bottom: 0.5rem;
}
.pod-status .pod-name {
flex: 1;
font-family: monospace;
font-size: 0.85rem;
}
.pod-status .pod-ready {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-left: 0.5rem;
}
.pod-status .pod-ready.healthy { background: rgba(40, 167, 69, 0.3); }
.pod-status .pod-ready.unhealthy { background: rgba(220, 53, 69, 0.3); }
/* Pipeline History */
.pipeline-item {
display: flex;
align-items: center;
padding: 0.75rem;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
margin-bottom: 0.5rem;
transition: background 0.3s;
}
.pipeline-item:hover {
background: var(--momo-page-accent-soft);
}
.pipeline-id {
font-family: monospace;
font-weight: 600;
width: 80px;
}
.pipeline-status {
width: 30px;
text-align: center;
}
.pipeline-info {
flex: 1;
margin-left: 1rem;
}
.pipeline-commit {
font-size: 0.85rem;
color: var(--momo-text-secondary);
}
.pipeline-time {
font-size: 0.8rem;
color: var(--momo-text-tertiary);
}
/* Summary Stats */
.stat-box {
text-align: center;
padding: 1.5rem;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
}
.stat-label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
margin-top: 0.5rem;
}
/* Action Buttons */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s;
border: none;
cursor: pointer;
}
.action-btn i {
margin-right: 0.5rem;
}
.action-btn.primary {
background: var(--momo-page-accent);
border: 1px solid var(--momo-page-accent-dark);
color: var(--momo-page-inverse);
}
.action-btn.danger {
background: var(--momo-danger);
border: 1px solid var(--momo-danger);
color: var(--momo-text-inverse);
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: none;
}
/* Loading State */
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--momo-border-light);
border-top-color: var(--momo-page-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Real-time indicator */
.realtime-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
background: rgba(40, 167, 69, 0.2);
border: 1px solid rgba(40, 167, 69, 0.5);
border-radius: 20px;
color: #28a745;
}
.realtime-dot {
width: 6px;
height: 6px;
background: #28a745;
border-radius: 50%;
margin-right: 6px;
animation: pulse 1s infinite;
}
/* Response time badge */
.response-time {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
}
.response-time.fast { color: #28a745; }
.response-time.medium { color: #ffc107; }
.response-time.slow { color: #dc3545; }
/* Issues Panel */
.issues-panel {
max-height: 400px;
overflow-y: auto;
}
.issue-item {
display: flex;
align-items: flex-start;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 4px solid;
}
.issue-item.critical { border-left-color: #dc3545; background: rgba(220, 53, 69, 0.1); }
.issue-item.warning { border-left-color: #ffc107; background: rgba(255, 193, 7, 0.1); }
.issue-icon {
font-size: 1.5rem;
margin-right: 1rem;
}
.issue-content {
flex: 1;
}
.issue-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.issue-detail {
font-size: 0.85rem;
color: var(--momo-text-secondary);
}
.issue-suggestion {
font-size: 0.8rem;
color: var(--momo-info-text);
margin-top: 0.5rem;
}
.issue-actions {
margin-left: 1rem;
}
.error-log-preview {
font-family: monospace;
font-size: 0.75rem;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
max-height: 100px;
overflow-y: auto;
white-space: pre-wrap;
color: var(--momo-danger-text);
}
/* Stage Error Tooltip */
.stage-error {
font-size: 0.7rem;
color: var(--momo-danger-text);
margin-top: 0.5rem;
max-width: 120px;
word-wrap: break-word;
}
/* Fix Button */
.btn-fix {
background: var(--momo-info);
border: 1px solid var(--momo-info);
color: var(--momo-text-inverse);
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s;
}
.btn-fix:hover {
transform: translateY(-2px);
background: var(--momo-info-text);
border-color: var(--momo-info-text);
}
.btn-fix:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Environment Error */
.env-error {
background: rgba(220, 53, 69, 0.2);
border: 1px solid rgba(220, 53, 69, 0.5);
border-radius: 8px;
padding: 0.75rem;
margin-top: 0.5rem;
}
.env-error-title {
color: #dc3545;
font-weight: 600;
font-size: 0.9rem;
}
.env-error-detail {
font-family: monospace;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="cicd-page">
<!-- Header -->
<div class="dashboard-header">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-rocket-takeoff me-2"></i>CI/CD Dashboard</h1>
<p class="mb-0 opacity-75">MOMO Pro System - 持續整合與部署監控</p>
</div>
<div class="d-flex align-items-center gap-3">
<div class="realtime-badge">
<span class="realtime-dot"></span>
即時更新
</div>
<span id="lastUpdate" class="text-white-50 small"></span>
</div>
</div>
</div>
</div>
<div class="container">
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="stat-box">
<div class="stat-value text-success" id="statSuccess">-</div>
<div class="stat-label">成功率 (%)</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="stat-box">
<div class="stat-value text-info" id="statToday">-</div>
<div class="stat-label">今日部署</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="stat-box">
<div id="uatStatus" class="stat-value">-</div>
<div class="stat-label">UAT 狀態</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="stat-box">
<div id="prodStatus" class="stat-value">-</div>
<div class="stat-label">PROD 狀態</div>
</div>
</div>
</div>
</div>
<!-- Pipeline Flow -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-2"></i>最新 Pipeline</span>
<span id="pipelineId" class="badge bg-secondary">#--</span>
</div>
<div class="card-body">
<div class="pipeline-flow" id="pipelineFlow">
<div class="loading-spinner">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Environments -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-hdd-stack me-2"></i>環境狀態
</div>
<div class="card-body" id="environmentsContainer">
<div class="loading-spinner">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-lightning me-2"></i>快速操作
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<button class="action-btn primary w-100" onclick="triggerDeploy('uat')">
<i class="bi bi-cloud-upload"></i>部署 UAT
</button>
</div>
<div class="col-6">
<button class="action-btn primary w-100" onclick="triggerDeploy('prod')">
<i class="bi bi-cloud-upload"></i>部署 PROD
</button>
</div>
<div class="col-6">
<button class="action-btn danger w-100" onclick="triggerRollback('uat')">
<i class="bi bi-arrow-counterclockwise"></i>回滾 UAT
</button>
</div>
<div class="col-6">
<button class="action-btn danger w-100" onclick="triggerRollback('prod')">
<i class="bi bi-arrow-counterclockwise"></i>回滾 PROD
</button>
</div>
</div>
<hr class="my-4 border-secondary">
<div class="row g-3">
<div class="col-12">
<a href="http://192.168.0.110:8929/root/momo-pro-system/-/pipelines" target="_blank" class="btn btn-outline-light w-100">
<i class="bi bi-box-arrow-up-right me-2"></i>開啟 GitLab Pipelines
</a>
</div>
<div class="col-6">
<a href="http://192.168.0.110:30030" target="_blank" class="btn btn-outline-info w-100">
<i class="bi bi-graph-up me-2"></i>Grafana
</a>
</div>
<div class="col-6">
<a href="http://192.168.0.110:5678" target="_blank" class="btn btn-outline-warning w-100">
<i class="bi bi-diagram-2 me-2"></i>n8n
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Issues Panel -->
<div class="card mb-4" id="issuesPanelCard" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center bg-danger bg-opacity-25">
<span><i class="bi bi-exclamation-triangle me-2"></i>問題與告警 <span id="issuesCount" class="badge bg-danger ms-2">0</span></span>
<div>
<button class="btn btn-sm btn-outline-warning me-2" onclick="runDiagnosis()">
<i class="bi bi-search me-1"></i>診斷
</button>
<button class="btn btn-sm btn-outline-info" onclick="triggerFullRepair()">
<i class="bi bi-wrench me-1"></i>一鍵修復
</button>
</div>
</div>
<div class="card-body issues-panel" id="issuesContainer">
</div>
</div>
<!-- Pipeline History -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history me-2"></i>部署歷史</span>
<a href="http://192.168.0.110:8929/root/momo-pro-system/-/pipelines" target="_blank" class="btn btn-sm btn-outline-light">
查看全部
</a>
</div>
<div class="card-body" id="pipelineHistory">
<div class="loading-spinner">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto" id="toastTitle">通知</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body" id="toastMessage"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 配置
const REFRESH_INTERVAL = 10000; // 10 秒刷新
let refreshTimer = null;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
startAutoRefresh();
});
// 自動刷新
function startAutoRefresh() {
refreshTimer = setInterval(loadDashboard, REFRESH_INTERVAL);
}
// 載入 Dashboard 數據
async function loadDashboard() {
try {
const response = await fetch('/api/cicd/status');
const data = await response.json();
if (data.success) {
updateSummary(data.summary);
updatePipelineFlow(data.latest_pipeline, data.latest_jobs);
updateEnvironments(data.environments);
updatePipelineHistory(data.pipelines);
updateIssuesPanel(data.issues);
updateLastUpdate();
}
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
// 更新問題面板
function updateIssuesPanel(issues) {
const panel = document.getElementById('issuesPanelCard');
const container = document.getElementById('issuesContainer');
const countBadge = document.getElementById('issuesCount');
if (!issues || issues.length === 0) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
countBadge.textContent = issues.length;
let html = '';
issues.forEach(issue => {
const severityClass = issue.severity === 'critical' ? 'critical' : 'warning';
const icon = issue.severity === 'critical' ? '🚨' : '⚠️';
html += `
<div class="issue-item ${severityClass}">
<span class="issue-icon">${icon}</span>
<div class="issue-content">
<div class="issue-title">${escapeHtml(issue.message)}</div>
<div class="issue-detail">
${issue.type === 'job' ? `<span class="badge bg-secondary me-1">${issue.stage}</span>` : ''}
${issue.type === 'runtime' ? `<span class="badge bg-info me-1">${issue.environment?.toUpperCase()}</span>` : ''}
${issue.error ? `<br><code>${escapeHtml(issue.error.substring(0, 100))}</code>` : ''}
</div>
${issue.fix_suggestion ? `<div class="issue-suggestion">💡 ${escapeHtml(issue.fix_suggestion)}</div>` : ''}
${issue.error_log ? `<div class="error-log-preview">${escapeHtml(issue.error_log.substring(0, 300))}</div>` : ''}
</div>
<div class="issue-actions">
${issue.auto_fixable ? `
<button class="btn-fix" onclick="triggerAutoFix('${issue.fix_action}', '${issue.environment || 'uat'}')">
<i class="bi bi-wrench me-1"></i>修復
</button>
` : ''}
</div>
</div>
`;
});
container.innerHTML = html;
}
// 更新摘要統計
function updateSummary(summary) {
document.getElementById('statSuccess').textContent = summary.success_rate || 0;
document.getElementById('statToday').textContent = summary.total_pipelines_today || 0;
const uatStatus = document.getElementById('uatStatus');
const prodStatus = document.getElementById('prodStatus');
if (summary.uat_healthy) {
uatStatus.innerHTML = '<span class="text-success">✅ 健康</span>';
} else {
uatStatus.innerHTML = '<span class="text-danger">❌ 異常</span>';
}
if (summary.prod_healthy) {
prodStatus.innerHTML = '<span class="text-success">✅ 健康</span>';
} else {
prodStatus.innerHTML = '<span class="text-danger">❌ 異常</span>';
}
}
// 更新 Pipeline 流程圖
function updatePipelineFlow(latestPipeline, latestJobs) {
const container = document.getElementById('pipelineFlow');
const pipelineIdEl = document.getElementById('pipelineId');
if (!latestPipeline) {
container.innerHTML = '<p class="text-center text-muted">暫無 Pipeline 數據</p>';
return;
}
pipelineIdEl.textContent = `#${latestPipeline.id}`;
// 如果已有 jobs 數據,直接使用
if (latestJobs && latestJobs.length > 0) {
const stages = groupJobsByStage(latestJobs);
renderPipelineFlowWithJobs(container, stages);
} else {
// 否則取得 Pipeline 的 Jobs
fetch(`/api/cicd/pipeline/${latestPipeline.id}`)
.then(res => res.json())
.then(data => {
if (data.success && data.stages) {
renderPipelineFlow(container, data.stages);
}
})
.catch(err => {
console.error('Failed to load pipeline details:', err);
renderSimplePipelineFlow(container, latestPipeline);
});
}
}
// 將 Jobs 按 Stage 分組
function groupJobsByStage(jobs) {
const stageOrder = ['test', 'build', 'deploy'];
const stages = {};
jobs.forEach(job => {
const stageName = job.stage || 'unknown';
if (!stages[stageName]) {
stages[stageName] = {
name: stageName,
status: 'pending',
jobs: [],
duration: 0,
error_info: null
};
}
stages[stageName].jobs.push(job);
// 更新 stage 狀態
if (job.status === 'failed') {
stages[stageName].status = 'failed';
stages[stageName].error_info = job.error_info;
stages[stageName].failure_reason = job.failure_reason;
} else if (job.status === 'running' && stages[stageName].status !== 'failed') {
stages[stageName].status = 'running';
} else if (job.status === 'success' && stages[stageName].status === 'pending') {
stages[stageName].status = 'success';
}
if (job.duration) {
stages[stageName].duration += job.duration;
}
});
// 按順序返回
return stageOrder.map(name => stages[name]).filter(s => s);
}
// 渲染 Pipeline 流程圖(帶 Job 詳情)
function renderPipelineFlowWithJobs(container, stages) {
let html = '';
stages.forEach((stage, index) => {
if (index > 0) {
html += '<div class="pipeline-arrow"></div>';
}
const errorMsg = stage.error_info?.message || stage.failure_reason || '';
const fixSuggestion = stage.error_info?.fix_suggestion || '';
html += `
<div class="pipeline-stage">
<div class="stage-box ${stage.status}" title="${escapeHtml(errorMsg)}">
<span class="stage-icon">${getStatusIcon(stage.status)}</span>
<span class="stage-name">${stage.name}</span>
</div>
<span class="stage-duration">${formatDuration(stage.duration)}</span>
${stage.status === 'failed' ? `
<div class="stage-error" title="${escapeHtml(fixSuggestion)}">
${escapeHtml(errorMsg.substring(0, 30))}${errorMsg.length > 30 ? '...' : ''}
</div>
` : ''}
</div>
`;
});
container.innerHTML = html;
}
// 渲染 Pipeline 流程圖
function renderPipelineFlow(container, stages) {
let html = '';
stages.forEach((stage, index) => {
if (index > 0) {
html += '<div class="pipeline-arrow"></div>';
}
html += `
<div class="pipeline-stage">
<div class="stage-box ${stage.status}">
<span class="stage-icon">${stage.status_icon}</span>
<span class="stage-name">${stage.name}</span>
</div>
<span class="stage-duration">${formatDuration(stage.duration)}</span>
</div>
`;
});
container.innerHTML = html;
}
// 簡易 Pipeline 流程圖
function renderSimplePipelineFlow(container, pipeline) {
const stages = ['test', 'build', 'deploy'];
let html = '';
stages.forEach((stage, index) => {
if (index > 0) {
html += '<div class="pipeline-arrow"></div>';
}
const status = index === 2 && pipeline.status === 'success' ? 'success' :
index < 2 && pipeline.status !== 'failed' ? 'success' :
pipeline.status === 'running' && index === 2 ? 'running' :
pipeline.status === 'failed' ? 'failed' : 'pending';
html += `
<div class="pipeline-stage">
<div class="stage-box ${status}">
<span class="stage-icon">${getStatusIcon(status)}</span>
<span class="stage-name">${stage}</span>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 更新環境狀態
function updateEnvironments(environments) {
const container = document.getElementById('environmentsContainer');
let html = '';
for (const [envId, env] of Object.entries(environments)) {
const healthClass = env.healthy ? 'healthy' : 'unhealthy';
const responseClass = env.response_time < 500 ? 'fast' :
env.response_time < 1000 ? 'medium' : 'slow';
html += `
<div class="env-card card mb-3 ${envId}">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<span class="env-icon">${env.icon}</span>
<div>
<h5 class="mb-0">${env.name} - ${env.label}</h5>
<a href="${env.url}" target="_blank" class="text-muted small">${env.url}</a>
</div>
<div class="ms-auto d-flex align-items-center">
<span class="status-indicator ${healthClass}"></span>
<span class="${env.healthy ? 'text-success' : 'text-danger'}">${env.healthy ? '健康' : '異常'}</span>
${env.response_time ? `<span class="response-time ${responseClass} ms-2">${env.response_time}ms</span>` : ''}
</div>
</div>
${env.version ? `<div class="mb-2"><small class="text-muted">版本:</small> <code>${env.version}</code></div>` : ''}
${!env.healthy && env.error ? `
<div class="env-error">
<div class="env-error-title">❌ 連線錯誤</div>
<div class="env-error-detail">${escapeHtml(env.error)}</div>
<button class="btn-fix mt-2" onclick="triggerAutoFix('diagnose', '${envId}')">
<i class="bi bi-search me-1"></i>診斷服務
</button>
</div>
` : ''}
<div class="env-details mt-2">
<strong class="d-block mb-2">Runtime 狀態:</strong>
${renderPods(env.pods, envId)}
</div>
</div>
</div>
`;
}
container.innerHTML = html;
}
// 渲染 runtime 狀態
function renderPods(pods, envId) {
if (!pods || pods.length === 0) {
return '<p class="text-muted small mb-0">Docker Compose runtime舊叢集資訊不適用</p>';
}
return pods.map(pod => `
<div class="pod-status">
<span class="status-indicator ${pod.healthy ? 'healthy' : 'unhealthy'}"></span>
<span class="pod-name">${pod.name}</span>
<span class="pod-ready ${pod.healthy ? 'healthy' : 'unhealthy'}">${pod.ready}</span>
<span class="text-muted small ms-2">${pod.age}</span>
${pod.restarts > 0 ? `<span class="badge bg-warning ms-2">${pod.restarts} 重啟</span>` : ''}
${!pod.healthy ? `<span class="badge bg-danger ms-2">${pod.status}</span>` : ''}
</div>
`).join('');
}
// 更新 Pipeline 歷史
function updatePipelineHistory(pipelines) {
const container = document.getElementById('pipelineHistory');
if (!pipelines || pipelines.length === 0) {
container.innerHTML = '<p class="text-center text-muted">暫無 Pipeline 記錄</p>';
return;
}
let html = '';
pipelines.slice(0, 10).forEach(p => {
html += `
<div class="pipeline-item">
<span class="pipeline-status">${p.status_icon}</span>
<span class="pipeline-id">#${p.id}</span>
<div class="pipeline-info">
<div class="pipeline-commit">${p.ref} @ ${p.sha}</div>
<div class="pipeline-time">${formatTime(p.created_at)} ${p.duration ? `${p.duration}` : ''}</div>
</div>
<a href="${p.web_url}" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
`;
});
container.innerHTML = html;
}
// 更新最後更新時間
function updateLastUpdate() {
const el = document.getElementById('lastUpdate');
const now = new Date();
el.textContent = `最後更新: ${now.toLocaleTimeString('zh-TW')}`;
}
// 觸發部署
async function triggerDeploy(env) {
if (!confirm(`確定要觸發 ${env.toUpperCase()} 部署嗎?`)) return;
try {
const response = await fetch('/api/cicd/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env })
});
const data = await response.json();
showNotification(data.success ? '成功' : '失敗', data.message || data.error);
if (data.success) {
setTimeout(loadDashboard, 2000);
}
} catch (error) {
showNotification('錯誤', '無法觸發部署');
}
}
// 觸發回滾
async function triggerRollback(env) {
if (!confirm(`確定要回滾 ${env.toUpperCase()} 嗎?這將恢復到上一個版本。`)) return;
try {
const response = await fetch('/api/cicd/rollback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env })
});
const data = await response.json();
showNotification(data.success ? '成功' : '失敗', data.message || data.error);
if (data.success) {
setTimeout(loadDashboard, 2000);
}
} catch (error) {
showNotification('錯誤', '無法執行回滾');
}
}
// 顯示通知
function showNotification(title, message, isError = false) {
document.getElementById('toastTitle').textContent = title;
document.getElementById('toastMessage').textContent = message;
const toastEl = document.getElementById('notificationToast');
if (isError) {
toastEl.classList.add('bg-danger');
} else {
toastEl.classList.remove('bg-danger');
}
const toast = new bootstrap.Toast(toastEl);
toast.show();
}
// 自動修復
async function triggerAutoFix(action, env) {
if (!confirm(`確定要執行自動修復 (${action}) 嗎?`)) return;
showNotification('執行中', `正在執行 ${action}...`);
try {
const response = await fetch('/api/cicd/auto-fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, environment: env })
});
const data = await response.json();
if (data.success) {
showNotification('✅ 修復完成', data.message);
setTimeout(loadDashboard, 3000);
} else {
showNotification('❌ 修復失敗', data.error, true);
}
} catch (error) {
showNotification('❌ 錯誤', '無法執行自動修復: ' + error, true);
}
}
// 完整修復
async function triggerFullRepair() {
const env = prompt('請輸入要修復的環境 (uat 或 prod):', 'uat');
if (!env || !['uat', 'prod'].includes(env)) {
showNotification('取消', '無效的環境');
return;
}
if (!confirm(`確定要對 ${env.toUpperCase()} 執行完整修復嗎?\n這會重啟 Registry 並執行 runtime 診斷,不會重啟舊叢集。`)) return;
showNotification('執行中', '正在執行完整修復...');
try {
const response = await fetch('/api/cicd/auto-fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'full_repair', environment: env })
});
const data = await response.json();
if (data.success) {
showNotification('✅ 完整修復完成', `已執行 ${data.results?.length || 0} 個修復動作`);
setTimeout(loadDashboard, 5000);
} else {
showNotification('❌ 修復失敗', data.error, true);
}
} catch (error) {
showNotification('❌ 錯誤', '無法執行完整修復: ' + error, true);
}
}
// 診斷
async function runDiagnosis() {
const env = prompt('請輸入要診斷的環境 (uat 或 prod):', 'uat');
if (!env || !['uat', 'prod'].includes(env)) {
showNotification('取消', '無效的環境');
return;
}
showNotification('執行中', `正在診斷 ${env.toUpperCase()} 環境...`);
try {
const response = await fetch('/api/cicd/diagnose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env })
});
const data = await response.json();
if (data.success) {
const diagnosis = data.diagnosis;
let message = `診斷結果: ${diagnosis.summary?.overall_status?.toUpperCase()}\n`;
message += `失敗: ${diagnosis.summary?.failed_count || 0}, 警告: ${diagnosis.summary?.warning_count || 0}\n\n`;
diagnosis.checks?.forEach(check => {
const icon = check.status === 'ok' ? '✅' : (check.status === 'warning' ? '⚠️' : '❌');
message += `${icon} ${check.name}: ${check.status}\n`;
});
if (diagnosis.summary?.recommendations?.length > 0) {
message += '\n建議動作:\n';
diagnosis.summary.recommendations.forEach(rec => {
message += `${rec.description}\n`;
});
}
alert(message);
} else {
showNotification('❌ 診斷失敗', data.error, true);
}
} catch (error) {
showNotification('❌ 錯誤', '無法執行診斷: ' + error, true);
}
}
// HTML 轉義
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 輔助函數
function getStatusIcon(status) {
const icons = {
'success': '✅', 'passed': '✅',
'failed': '❌', 'running': '🔄',
'pending': '⏳', 'canceled': '⛔'
};
return icons[status] || '❓';
}
function formatDuration(seconds) {
if (!seconds) return '-';
if (seconds < 60) return `${Math.round(seconds)}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${Math.round(seconds % 60)}`;
return `${Math.floor(seconds / 3600)}${Math.floor((seconds % 3600) / 60)}`;
}
function formatTime(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleString('zh-TW', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
</script>
{% endblock %}