1213 lines
44 KiB
HTML
1213 lines
44 KiB
HTML
{% 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 %}
|