Files
ewoooc/templates/cicd_dashboard.html
ogt 903cf1a27a
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
fix: align deploy health checks with live endpoint
2026-06-25 14:45:02 +08:00

1237 lines
45 KiB
HTML

{% extends "ewoooc_base.html" %}
{% block title %}部署監控 - 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 {
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-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>部署監控</h1>
<p class="mb-0 opacity-75">PChome 業績成長自動化作戰系統 · 持續整合與部署監控</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>
<!-- 部署流程 -->
<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>最新部署流程</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 部署紀錄
</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>
<!-- 部署歷史 -->
<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">${displayStageName(issue.stage)}</span>` : ''}
${issue.type === 'runtime' ? `<span class="badge bg-info me-1">${issue.environment?.toUpperCase()}</span>` : ''}
${issue.error ? `<br><span class="text-muted">${escapeHtml(issue.error.substring(0, 120))}</span>` : ''}
</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>';
}
}
// 更新部署流程圖
function updatePipelineFlow(latestPipeline, latestJobs) {
const container = document.getElementById('pipelineFlow');
const pipelineIdEl = document.getElementById('pipelineId');
if (!latestPipeline) {
container.innerHTML = '<p class="text-center text-muted">暫無部署流程資料</p>';
return;
}
pipelineIdEl.textContent = `#${latestPipeline.id}`;
// 如果已有 jobs 數據,直接使用
if (latestJobs && latestJobs.length > 0) {
const stages = groupJobsByStage(latestJobs);
renderPipelineFlowWithJobs(container, stages);
} else {
// 否則取得部署流程的工作項目
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('部署流程細節讀取失敗:', err);
renderSimplePipelineFlow(container, latestPipeline);
});
}
}
// 將工作項目按階段分組
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);
}
// 渲染部署流程圖(帶工作詳情)
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">${displayStageName(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;
}
// 渲染部署流程圖
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">${displayStageName(stage.name)}</span>
</div>
<span class="stage-duration">${formatDuration(stage.duration)}</span>
</div>
`;
});
container.innerHTML = html;
}
// 簡易部署流程圖
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">${displayStageName(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">執行環境狀態:</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 執行環境;外部叢集資訊不適用</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('');
}
// 更新部署歷史
function updatePipelineHistory(pipelines) {
const container = document.getElementById('pipelineHistory');
if (!pipelines || pipelines.length === 0) {
container.innerHTML = '<p class="text-center text-muted">暫無部署流程紀錄</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這會執行必要服務修復與環境診斷。`)) 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 = `診斷結果: ${displayStatusText(diagnosis.summary?.overall_status)}\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}: ${displayStatusText(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 displayStageName(stage) {
const labels = {
test: '測試',
build: '建置',
deploy: '部署',
unknown: '未分類',
};
return labels[stage] || stage || '未分類';
}
function displayStatusText(status) {
const labels = {
ok: '正常',
warning: '注意',
failed: '失敗',
success: '成功',
running: '執行中',
pending: '等待中',
canceled: '已取消',
completed: '已完成',
};
return labels[status] || status || '未知';
}
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 %}