8847 lines
329 KiB
HTML
8847 lines
329 KiB
HTML
<!doctype html>
|
||
<html lang="fa" dir="rtl">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>دستیار انتشار</title>
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
<style>
|
||
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap");
|
||
|
||
:root {
|
||
--primary: #1a365d;
|
||
--primary-light: #2c5282;
|
||
--primary-dark: #0f2341;
|
||
--accent: #c9a961;
|
||
--accent-dark: #a8893f;
|
||
--accent-light: #e0c789;
|
||
--bg: #f5f7fa;
|
||
--board-bg: #e2e8f0;
|
||
--card-bg: #fff;
|
||
--panel-bg: #fff;
|
||
--text: #1a202c;
|
||
--text-muted: #4a5568;
|
||
--text-light: #718096;
|
||
--border: #cbd5e0;
|
||
--border-light: #e2e8f0;
|
||
--success: #38a169;
|
||
--danger: #e53e3e;
|
||
--warning: #dd6b20;
|
||
--info: #3182ce;
|
||
--shadow:
|
||
0 4px 6px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(26, 54, 93, 0.1);
|
||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06);
|
||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||
--radius: 10px;
|
||
--radius-sm: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--primary: #2c5282;
|
||
--primary-light: #3b6fb5;
|
||
--primary-dark: #1a365d;
|
||
--accent: #e0c789;
|
||
--accent-dark: #c9a961;
|
||
--accent-light: #f0d9a0;
|
||
--bg: #1a202c;
|
||
--board-bg: #2d3748;
|
||
--card-bg: #2d3748;
|
||
--panel-bg: #1f2937;
|
||
--text: #e2e8f0;
|
||
--text-muted: #cbd5e0;
|
||
--text-light: #a0aec0;
|
||
--border: #4a5568;
|
||
--border-light: #374151;
|
||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3), 0 10px 20px rgba(0, 0, 0, 0.4);
|
||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: "Vazirmatn", Tahoma, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
font-size: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition:
|
||
background 0.3s,
|
||
color 0.3s;
|
||
}
|
||
|
||
.app-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary) 0%,
|
||
var(--primary-light) 100%
|
||
);
|
||
color: #fff;
|
||
padding: 0 20px;
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 3px solid var(--accent);
|
||
box-shadow: var(--shadow);
|
||
z-index: 100;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.app-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
.app-logo {
|
||
width: 44px;
|
||
height: 44px;
|
||
background: var(--accent);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.app-brand-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.app-title {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
}
|
||
|
||
.app-subtitle {
|
||
font-size: 11px;
|
||
opacity: 0.85;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.btn {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary-light),
|
||
var(--primary)
|
||
);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 8px 14px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-1px);
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.btn-accent {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
color: var(--primary-dark);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: rgba(26, 54, 93, 0.08);
|
||
border: 1px solid var(--border);
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .btn-ghost {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, var(--danger), #c53030);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.btn-xs {
|
||
padding: 2px 6px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.app-main {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
background: var(--panel-bg);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: width 0.3s;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
z-index: 50;
|
||
}
|
||
|
||
.sidebar.collapsed {
|
||
width: 0;
|
||
}
|
||
|
||
.sidebar-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
padding: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 2px solid var(--accent);
|
||
min-height: 48px;
|
||
}
|
||
|
||
.sidebar-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.sidebar-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.sidebar-group {
|
||
margin-bottom: 6px;
|
||
border: 1px solid var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
background: var(--card-bg);
|
||
}
|
||
|
||
.sidebar-group-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
user-select: none;
|
||
}
|
||
|
||
.sidebar-group-header .group-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.sidebar-group-header .group-arrow {
|
||
transition: transform 0.3s;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.sidebar-group.collapsed .group-arrow {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.sidebar-group.collapsed .sidebar-group-body {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-group-body {
|
||
padding: 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.sidebar-btn {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-light);
|
||
color: var(--text);
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
text-align: right;
|
||
width: 100%;
|
||
}
|
||
|
||
.sidebar-btn:hover {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
}
|
||
|
||
.tabs-bar {
|
||
background: linear-gradient(135deg, #0f1724 0%, #1a2a44 100%);
|
||
border-bottom: 2px solid var(--accent);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
min-height: 48px;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-controls {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.toolbar-icon-btn {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
color: #fff;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.toolbar-icon-btn:hover:not(:disabled) {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.toolbar-search {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
max-width: 320px;
|
||
position: relative;
|
||
}
|
||
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 7px 32px 7px 12px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.toolbar-filters {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.filter-dropdown {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.filter-select {
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 7px 4px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
min-width: 110px;
|
||
}
|
||
|
||
.filter-select option {
|
||
background: var(--primary-dark);
|
||
color: #fff;
|
||
}
|
||
|
||
.toolbar-stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-right: auto;
|
||
padding-right: 8px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
|
||
.stat-value {
|
||
font-weight: 800;
|
||
color: var(--accent);
|
||
background: rgba(201, 169, 97, 0.15);
|
||
padding: 2px 7px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.board-area {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 24px;
|
||
background: var(--board-bg);
|
||
}
|
||
|
||
.board-section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.board-section.collapsed .section-content {
|
||
display: none;
|
||
}
|
||
|
||
.board-section.collapsed .collapse-icon {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.collapse-icon {
|
||
transition: transform 0.3s;
|
||
font-size: 14px;
|
||
display: inline-block;
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.board-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
|
||
.board-header-title {
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
[data-theme="dark"] .board-header-title {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.board-header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.projects-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.project-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: all 0.25s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.project-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.project-card-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary) 0%,
|
||
var(--primary-light) 100%
|
||
);
|
||
color: #fff;
|
||
padding: 16px;
|
||
}
|
||
|
||
.project-card-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.project-card-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
flex: 1;
|
||
}
|
||
|
||
.project-card-desc {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.project-card-body {
|
||
padding: 14px 16px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.project-stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.project-stat-value {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .project-stat-value {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.project-type-badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.type-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.type-backend {
|
||
background: #ebf4ff;
|
||
color: #2b6cb0;
|
||
}
|
||
|
||
.type-frontend {
|
||
background: #f0fff4;
|
||
color: #276749;
|
||
}
|
||
|
||
.type-service {
|
||
background: #fefcbf;
|
||
color: #975a16;
|
||
}
|
||
|
||
.type-database {
|
||
background: #fed7e2;
|
||
color: #97266d;
|
||
}
|
||
|
||
.type-mobile {
|
||
background: #e9d8fd;
|
||
color: #553c9a;
|
||
}
|
||
|
||
.type-infrastructure {
|
||
background: #feebc8;
|
||
color: #9c4221;
|
||
}
|
||
|
||
.project-card-footer {
|
||
padding: 12px 16px;
|
||
background: var(--border-light);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.env-card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.env-card-item {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.env-card-preview {
|
||
height: 60px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
|
||
.env-card-body {
|
||
padding: 14px 16px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.env-card-name {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.env-card-desc {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.env-card-footer {
|
||
padding: 10px 16px;
|
||
background: var(--border-light);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.board-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.board-empty-icon {
|
||
font-size: 80px;
|
||
opacity: 0.3;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.board-empty h2 {
|
||
font-size: 20px;
|
||
color: var(--primary);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
[data-theme="dark"] .board-empty h2 {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.no-results {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border-radius: var(--radius);
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(26, 54, 93, 0.6);
|
||
backdrop-filter: blur(3px);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--panel-bg);
|
||
color: var(--text);
|
||
border-radius: 12px;
|
||
width: 100%;
|
||
max-width: 600px;
|
||
max-height: 90vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.modal-lg {
|
||
max-width: 800px;
|
||
}
|
||
|
||
.modal-xl {
|
||
max-width: 1000px;
|
||
}
|
||
|
||
.modal-galaxy {
|
||
max-width: 1200px;
|
||
width: 100%;
|
||
height: 85vh;
|
||
max-height: 85vh;
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.galaxy-search {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
padding: 0 8px;
|
||
height: 32px;
|
||
min-width: 230px;
|
||
}
|
||
|
||
.galaxy-search-icon {
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.galaxy-search input {
|
||
flex: 1;
|
||
width: 140px;
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
outline: none;
|
||
}
|
||
|
||
.galaxy-collapse-badge {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.galaxy-search input::placeholder {
|
||
color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
|
||
.galaxy-search-clear {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.galaxy-search-clear.visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.galaxy-node.galaxy-dim {
|
||
opacity: 0.12;
|
||
}
|
||
|
||
.galaxy-node.galaxy-match > circle:nth-of-type(2) {
|
||
stroke: #ffd700;
|
||
stroke-width: 3;
|
||
}
|
||
|
||
.galaxy-link.galaxy-dim {
|
||
stroke-opacity: 0.04 !important;
|
||
}
|
||
|
||
.modal-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 3px solid var(--accent);
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border: none;
|
||
color: #fff;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
gap: 8px;
|
||
background: var(--border-light);
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: 20px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.detail-section-header {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
padding: 12px 16px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.detail-section-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-section-body {
|
||
padding: 16px;
|
||
background: var(--card-bg);
|
||
}
|
||
|
||
.detail-info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-info-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.detail-info-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-info-value {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.subproject-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.subproject-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.subproject-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.subproject-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.subproject-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.subproject-meta {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.deployment-section {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed var(--border);
|
||
}
|
||
|
||
.deployment-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.deployment-header-title {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.deployment-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.deployment-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.deployment-item-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.deployment-env-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.deployment-version {
|
||
font-family: "Vazirmatn", monospace;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
font-size: 11px;
|
||
}
|
||
|
||
[data-theme="dark"] .deployment-version {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.deployment-date {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.deployment-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.deployment-notes {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
padding-top: 4px;
|
||
border-top: 1px dashed var(--border-light);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.deployment-req {
|
||
font-size: 10px;
|
||
color: var(--info);
|
||
width: 100%;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.empty-deployments {
|
||
text-align: center;
|
||
padding: 12px;
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
background: var(--border-light);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.empty-subprojects {
|
||
text-align: center;
|
||
padding: 30px;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] .form-label {
|
||
color: var(--accent-light);
|
||
}
|
||
|
||
.form-label .required {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea,
|
||
.form-select {
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-textarea:focus,
|
||
.form-select:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.15);
|
||
}
|
||
|
||
.form-textarea {
|
||
resize: vertical;
|
||
min-height: 70px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.form-error {
|
||
font-size: 11px;
|
||
color: var(--danger);
|
||
margin-top: 4px;
|
||
display: none;
|
||
}
|
||
|
||
.form-error.visible {
|
||
display: block;
|
||
}
|
||
|
||
.color-input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.color-input-wrapper input[type="color"] {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
|
||
.color-preview-text {
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.webhook-section {
|
||
background: var(--border-light);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 14px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.webhook-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.webhook-section-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.webhook-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 36px;
|
||
height: 20px;
|
||
}
|
||
|
||
.switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background: var(--border);
|
||
transition: 0.3s;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.slider::before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 14px;
|
||
width: 14px;
|
||
right: 3px;
|
||
bottom: 3px;
|
||
background: #fff;
|
||
transition: 0.3s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
input:checked + .slider {
|
||
background: var(--success);
|
||
}
|
||
|
||
input:checked + .slider::before {
|
||
transform: translateX(-16px);
|
||
}
|
||
|
||
.webhook-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding: 8px 10px;
|
||
background: var(--card-bg);
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.webhook-status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
.webhook-status-dot.success {
|
||
background: var(--success);
|
||
}
|
||
|
||
.webhook-status-dot.error {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
z-index: 2000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toast {
|
||
background: var(--primary);
|
||
color: #fff;
|
||
padding: 12px 18px;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-lg);
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 250px;
|
||
}
|
||
|
||
.toast.success {
|
||
background: var(--success);
|
||
}
|
||
|
||
.toast.error {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.toast.warning {
|
||
background: var(--warning);
|
||
}
|
||
|
||
.app-footer {
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--primary),
|
||
var(--primary-light)
|
||
);
|
||
color: #fff;
|
||
padding: 8px 20px;
|
||
font-size: 11px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-top: 2px solid var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.footer-brand {
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.form-input[readonly],
|
||
.form-textarea[readonly] {
|
||
background: var(--border-light);
|
||
color: var(--text-muted);
|
||
cursor: not-allowed;
|
||
opacity: 0.85;
|
||
border-style: dashed;
|
||
}
|
||
|
||
.readonly-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
background: var(--border-light);
|
||
border: 1px solid var(--border);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
.readonly-badge::before {
|
||
content: "🔒";
|
||
font-size: 10px;
|
||
}
|
||
|
||
.readonly-info {
|
||
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
|
||
border: 1px solid #90cdf4;
|
||
border-right: 4px solid var(--info);
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-bottom: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.dependency-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 20px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dependency-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.dependency-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
}
|
||
|
||
.dependency-project-name {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.dependency-search-box {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.dependency-search-input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.dependency-search-results {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dependency-project-result {
|
||
margin-bottom: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dependency-project-add-btn {
|
||
padding: 10px 12px;
|
||
background: var(--card-bg);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
width: 100%;
|
||
border: none;
|
||
font-family: inherit;
|
||
text-align: right;
|
||
}
|
||
|
||
.dependency-project-add-btn:hover {
|
||
background: var(--accent-light);
|
||
}
|
||
|
||
.empty-dependencies {
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
background: var(--border-light);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.dependency-section-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] .dependency-section-title {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.env-color-preview {
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
font-weight: 700;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.env-read-only-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.req-deployment-box {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px;
|
||
margin-bottom: 8px;
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.req-deployment-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.req-deployment-select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.galaxy-toolbar {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.galaxy-toolbar .btn {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 5px 10px;
|
||
}
|
||
|
||
.galaxy-toolbar .btn:hover {
|
||
background: var(--accent);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.galaxy-modal-body {
|
||
padding: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #0a0e27;
|
||
}
|
||
|
||
.galaxy-chart-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 400px;
|
||
position: relative;
|
||
}
|
||
|
||
.galaxy-chart-container svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
|
||
.galaxy-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
text-align: center;
|
||
font-size: 16px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.galaxy-legend {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
right: 16px;
|
||
background: rgba(10, 14, 39, 0.85);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 10px;
|
||
padding: 12px 16px;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
z-index: 10;
|
||
max-width: 220px;
|
||
}
|
||
|
||
.galaxy-legend-title {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
margin-bottom: 10px;
|
||
color: var(--accent-light);
|
||
}
|
||
|
||
.galaxy-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.galaxy-legend-color {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px currentColor;
|
||
}
|
||
|
||
.galaxy-info-panel {
|
||
position: absolute;
|
||
top: 16px;
|
||
left: 16px;
|
||
background: rgba(10, 14, 39, 0.9);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 10px;
|
||
padding: 14px 18px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
z-index: 10;
|
||
max-width: 340px;
|
||
/* تغییر از 280px به 340px */
|
||
max-height: 400px;
|
||
/* اضافه شد */
|
||
overflow-y: auto;
|
||
/* اضافه شد */
|
||
display: none;
|
||
}
|
||
|
||
.galaxy-info-panel.visible {
|
||
display: block;
|
||
}
|
||
|
||
.galaxy-info-panel h4 {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--accent-light);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.galaxy-info-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
gap: 12px;
|
||
}
|
||
|
||
.galaxy-info-label {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.galaxy-info-value {
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
text-align: left;
|
||
}
|
||
|
||
.galaxy-zoom-indicator {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 16px;
|
||
background: rgba(10, 14, 39, 0.85);
|
||
border: 1px solid rgba(201, 169, 97, 0.3);
|
||
border-radius: 8px;
|
||
padding: 6px 12px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
z-index: 10;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.galaxy-stats {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.galaxy-stat-value {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
background: rgba(26, 54, 93, 0.1);
|
||
padding: 2px 8px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
[data-theme="dark"] .galaxy-stat-value {
|
||
color: var(--accent);
|
||
background: rgba(201, 169, 97, 0.15);
|
||
}
|
||
/* ── جستجو در نمودار کهکشانی ── */
|
||
.galaxy-search {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
padding: 0 8px;
|
||
height: 32px;
|
||
min-width: 230px;
|
||
}
|
||
.galaxy-search-icon {
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
.galaxy-search input {
|
||
flex: 1;
|
||
width: 140px;
|
||
background: transparent;
|
||
border: none;
|
||
color: #fff;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
outline: none;
|
||
}
|
||
.galaxy-search input::placeholder {
|
||
color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
.galaxy-search-clear {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.galaxy-search-clear.visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* ── پنل لیست نتایج جستجو ── */
|
||
.galaxy-search-results {
|
||
position: absolute;
|
||
top: 60px;
|
||
right: 16px;
|
||
width: 320px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
background: rgba(10, 14, 39, 0.95);
|
||
border: 1px solid rgba(201, 169, 97, 0.35);
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
z-index: 20;
|
||
display: none;
|
||
padding: 6px;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
.galaxy-search-results.visible {
|
||
display: block;
|
||
}
|
||
.galaxy-search-result-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
.galaxy-search-result-item:hover {
|
||
background: rgba(201, 169, 97, 0.25);
|
||
}
|
||
.galaxy-search-result-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
.galaxy-search-result-text {
|
||
min-width: 0;
|
||
}
|
||
.galaxy-search-result-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.galaxy-search-result-sub {
|
||
font-size: 10px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.galaxy-search-empty {
|
||
padding: 14px;
|
||
text-align: center;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ── هایلایت و کمرنگسازی نودها ── */
|
||
.galaxy-node.galaxy-dim {
|
||
opacity: 0.12;
|
||
}
|
||
.galaxy-node.galaxy-match > circle:nth-of-type(2) {
|
||
stroke: #ffd700;
|
||
stroke-width: 3;
|
||
}
|
||
.galaxy-link.galaxy-dim {
|
||
stroke-opacity: 0.04 !important;
|
||
}
|
||
.galaxy-node.galaxy-focus > circle:nth-of-type(2) {
|
||
stroke: #ffd700;
|
||
stroke-width: 4;
|
||
animation: galaxyPulse 0.9s ease-in-out 2;
|
||
}
|
||
.galaxy-node circle {
|
||
transform-box: fill-box;
|
||
transform-origin: center;
|
||
}
|
||
@keyframes galaxyPulse {
|
||
0%,
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(1.15);
|
||
}
|
||
}
|
||
|
||
.dashboard-modal-body {
|
||
padding: 20px;
|
||
background: var(--bg);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.dashboard-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.dashboard-kpi-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.dashboard-kpi-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 16px;
|
||
text-align: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dashboard-kpi-card::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
left: 0;
|
||
height: 3px;
|
||
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||
}
|
||
|
||
.dashboard-kpi-icon {
|
||
font-size: 28px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.dashboard-kpi-value {
|
||
font-size: 28px;
|
||
font-weight: 800;
|
||
color: var(--primary);
|
||
line-height: 1;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-kpi-value {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dashboard-kpi-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.dashboard-charts-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.dashboard-chart-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dashboard-chart-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-chart-header {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dashboard-chart-body {
|
||
padding: 16px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 220px;
|
||
}
|
||
|
||
.dashboard-chart-body svg {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.dashboard-empty-chart {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
gap: 8px;
|
||
text-align: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.dashboard-empty-chart span {
|
||
font-size: 32px;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.dashboard-footer-stats {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.dashboard-footer-stats .stat-highlight {
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
[data-theme="dark"] .dashboard-footer-stats .stat-highlight {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.d3-tooltip {
|
||
position: fixed;
|
||
background: var(--primary-dark);
|
||
color: #fff;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
z-index: 3000;
|
||
display: none;
|
||
font-family: "Vazirmatn", sans-serif;
|
||
}
|
||
|
||
.d3-tooltip.visible {
|
||
display: block;
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<header class="app-header">
|
||
<div class="app-brand">
|
||
<div class="app-logo">📊</div>
|
||
<div class="app-brand-text">
|
||
<div class="app-title" id="headerTitle">دستیار انتشار</div>
|
||
<div class="app-subtitle" id="headerSubtitle">
|
||
مدیریت نسخهها و نیازمندیهای محصولات
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div class="tabs-bar" id="advancedToolbar">
|
||
<div class="toolbar-controls">
|
||
<button class="toolbar-icon-btn" id="sidebarToggle" title="سایدبار">
|
||
<span id="sidebarIcon">✕</span>
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="themeToggle" title="تم">
|
||
<span id="themeIcon">🌙</span>
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="undoBtn" title="Ctrl+Z" disabled>
|
||
↩
|
||
</button>
|
||
<button class="toolbar-icon-btn" id="redoBtn" title="Ctrl+Y" disabled>
|
||
↪
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-search">
|
||
<span style="position: absolute; right: 10px; opacity: 0.7">🔍</span
|
||
><input
|
||
type="text"
|
||
class="search-input"
|
||
id="toolbarSearch"
|
||
placeholder="جستجو..."
|
||
/>
|
||
</div>
|
||
<div class="toolbar-filters">
|
||
<div class="filter-dropdown">
|
||
<span>🏷️</span
|
||
><select class="filter-select" id="filterType">
|
||
<option value="">همه انواع</option>
|
||
<option value="backend">بکاند</option>
|
||
<option value="frontend">فرانتاند</option>
|
||
<option value="service">میکروسرویس</option>
|
||
<option value="database">پایگاه داده</option>
|
||
<option value="mobile">موبایل</option>
|
||
<option value="infrastructure">زیرساخت</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-dropdown">
|
||
<span>🌐</span
|
||
><select class="filter-select" id="filterEnvironment">
|
||
<option value="">همه محیطها</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar-stats">
|
||
<div class="stat-item">
|
||
📋 <span class="stat-value" id="statTotal">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
📦 <span class="stat-value" id="statProjects">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
🌿 <span class="stat-value" id="statSubProjects">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
🔗 <span class="stat-value" id="statDependencies">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="app-main">
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="sidebar-title">🛠️ ابزارها</div>
|
||
</div>
|
||
<div class="sidebar-body" id="sidebarBody"></div>
|
||
</aside>
|
||
<main class="board-area" id="boardArea"></main>
|
||
</div>
|
||
<footer class="app-footer" id="appFooter">
|
||
<div>
|
||
<span class="footer-brand">طراح و توسعهدهنده:</span> هادی خزاعی اصل
|
||
<span style="margin: 0 10px">|</span
|
||
><span class="footer-brand">شرکت:</span> رایان پرداز کاوش
|
||
</div>
|
||
<div><span class="footer-brand">نسخه:</span> 1.1.0</div>
|
||
</footer>
|
||
|
||
<!-- Settings Modal -->
|
||
<div class="modal-overlay" id="settingsModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">
|
||
<span>⚙️</span><span>تنظیمات محصول</span>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="readonly-info">
|
||
ℹ️ اطلاعات پایه محصول فقط قابل مشاهده هستند.
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>عنوان محصول
|
||
<span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="settingProjectTitle"
|
||
readonly
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>نسخه <span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="settingVersion"
|
||
readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>توضیحات <span class="readonly-badge">فقط خواندنی</span></label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="settingDescription"
|
||
readonly
|
||
></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>توسعهدهنده
|
||
<span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="settingDeveloper"
|
||
readonly
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>شرکت <span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="settingCompany"
|
||
readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="webhook-section">
|
||
<div class="webhook-section-header">
|
||
<div class="webhook-section-title">🔗 تنظیمات وبهوک</div>
|
||
<div class="webhook-toggle">
|
||
<span>فعالسازی</span
|
||
><label class="switch"
|
||
><input type="checkbox" id="settingWebhookEnabled" /><span
|
||
class="slider"
|
||
></span
|
||
></label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>آدرس URL وبهوک <span class="required">*</span></label
|
||
><input
|
||
type="url"
|
||
class="form-input"
|
||
id="settingWebhookUrl"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>شناسه کلاینت <span class="required">*</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="settingWebhookClientId"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
<div class="webhook-status">
|
||
<span class="webhook-status-dot" id="webhookStatusDot"></span
|
||
><span id="webhookStatusText">وضعیت: غیرفعال</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveSettingsBtn">💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Environment Modals -->
|
||
<div class="modal-overlay" id="envManagerModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🌍 افزودن محیط عملیاتی جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>نام محیط <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="newEnvName" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><input type="text" class="form-input" id="newEnvDesc" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ پسزمینه</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="newEnvBgColor" value="#38a169" /><span
|
||
class="color-preview-text"
|
||
id="newEnvBgColorText"
|
||
>#38a169</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ متن</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="newEnvTextColor" value="#ffffff" /><span
|
||
class="color-preview-text"
|
||
id="newEnvTextColorText"
|
||
>#ffffff</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewEnvBtn">💾 ثبت</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="envEditModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش محیط عملیاتی</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
|
||
><input type="text" class="form-input" id="editEnvId" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">نام <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="editEnvName" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><input type="text" class="form-input" id="editEnvDesc" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ پسزمینه</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="editEnvBgColor" /><span
|
||
class="color-preview-text"
|
||
id="editEnvBgColorText"
|
||
></span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">رنگ متن</label>
|
||
<div class="color-input-wrapper">
|
||
<input type="color" id="editEnvTextColor" /><span
|
||
class="color-preview-text"
|
||
id="editEnvTextColorText"
|
||
></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">پیشنمایش</label>
|
||
<div id="editEnvPreview" class="env-color-preview">نمونه</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveEnvEditBtn">💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Product Modals -->
|
||
<div class="modal-overlay" id="projectDetailModal">
|
||
<div class="modal modal-xl">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="projectDetailTitle">📦 جزئیات محصول</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body" id="projectDetailBody"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-danger btn-sm" id="projectDetailDeleteBtn">
|
||
🗑️ حذف محصول</button
|
||
><button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="productEditModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش محصول</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="editProductId"
|
||
readonly
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>عنوان <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="editProductTitle" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="editProductDescription"
|
||
></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک</label
|
||
><input type="text" class="form-input" id="editProductOwner" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>تاریخ ایجاد
|
||
<span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="editProductCreatedAt"
|
||
readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveProductEditBtn">
|
||
💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="addProductModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">➕ ایجاد محصول جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>عنوان <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="newProductTitle" />
|
||
<div class="form-error" id="newProductTitleError">
|
||
عنوان محصول الزامی است و باید حداقل ۳ کاراکتر باشد
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="newProductDescription"
|
||
></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">مالک</label
|
||
><input type="text" class="form-input" id="newProductOwner" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>تاریخ ایجاد
|
||
<span class="readonly-badge">فقط خواندنی</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="newProductCreatedAt"
|
||
readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewProductBtn">💾 ثبت</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="addProjectModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">➕ افزودن پروژه جدید</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>عنوان <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="newProjectTitle" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>نوع <span class="required">*</span></label
|
||
><select class="form-select" id="newProjectType">
|
||
<option value="">انتخاب کنید...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">فریمورک</label
|
||
><input type="text" class="form-input" id="newProjectFramework" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">زبان</label
|
||
><input type="text" class="form-input" id="newProjectLanguage" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">آدرس مخزن</label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="newProjectRepo"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="newProjectDescription"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveNewProjectBtn">💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="editProjectModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">✏️ ویرایش پروژه</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>شناسه <span class="readonly-badge">فقط خواندنی</span></label
|
||
><input type="text" class="form-input" id="editProjId" readonly />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>عنوان <span class="required">*</span></label
|
||
><input type="text" class="form-input" id="editProjTitle" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>نوع <span class="required">*</span></label
|
||
><select class="form-select" id="editProjType">
|
||
<option value="">انتخاب کنید...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">فریمورک</label
|
||
><input type="text" class="form-input" id="editProjFramework" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">زبان</label
|
||
><input type="text" class="form-input" id="editProjLanguage" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">آدرس مخزن</label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="editProjRepo"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">توضیحات</label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="editProjDescription"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveProjEditBtn">💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Deployment Modal -->
|
||
<div class="modal-overlay" id="deploymentModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="deploymentModalTitle">
|
||
🚀 افزودن نسخه استقرار
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group" id="deploymentEnvGroup"></div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>نسخه <span class="required">*</span></label
|
||
><input
|
||
type="text"
|
||
class="form-input"
|
||
id="depVersion"
|
||
dir="ltr"
|
||
placeholder="مثلاً: 1.2.3"
|
||
/>
|
||
<div class="form-hint">از فرمت SemVer استفاده کنید</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>تاریخ و زمان استقرار <span class="required">*</span></label
|
||
><input
|
||
type="datetime-local"
|
||
class="form-input"
|
||
id="depDate"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label"
|
||
>ریلیز نوت <span class="required">*</span></label
|
||
><textarea
|
||
class="form-textarea"
|
||
id="depReleaseNotes"
|
||
rows="3"
|
||
placeholder="توضیحات تغییرات این نسخه..."
|
||
></textarea>
|
||
<div class="form-hint">
|
||
شرح تغییرات، باگفیکسها و ویژگیهای جدید این نسخه
|
||
</div>
|
||
</div>
|
||
<div id="requiredDeploymentsSection"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-accent" id="saveDeploymentBtn">💾 ذخیره</button
|
||
><button class="btn btn-ghost" data-close-modal>انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependency Manager Modal -->
|
||
<div class="modal-overlay" id="dependencyManagerModal">
|
||
<div class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔗 مدیریت وابستگیها</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="readonly-info">
|
||
ℹ️ هر وابستگی فقط شامل یک پروژه است. انتخاب نسخه و محیط در زمان ثبت
|
||
استقرار انجام میشود.
|
||
</div>
|
||
<div class="dependency-section-title">📋 وابستگیهای فعلی</div>
|
||
<div id="currentDependenciesList" class="dependency-list"></div>
|
||
<div class="dependency-search-box">
|
||
<div class="dependency-section-title">🔍 افزودن وابستگی جدید</div>
|
||
<input
|
||
type="text"
|
||
class="dependency-search-input"
|
||
id="dependencySearch"
|
||
placeholder="جستجو در پروژهها..."
|
||
/>
|
||
<div
|
||
id="dependencySearchResults"
|
||
class="dependency-search-results"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Galaxy Chart Modal -->
|
||
<div class="modal-overlay" id="galaxyChartModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🌌 نمودار کهکشانی محصولات</div>
|
||
<div class="galaxy-toolbar">
|
||
<div class="galaxy-search">
|
||
<span class="galaxy-search-icon">🔍</span>
|
||
<input
|
||
type="text"
|
||
id="galaxySearchInput"
|
||
placeholder="جستجو در نمودار..."
|
||
autocomplete="off"
|
||
/>
|
||
<button
|
||
class="galaxy-search-clear"
|
||
id="galaxySearchClear"
|
||
title="پاک کردن"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<button
|
||
class="btn btn-sm"
|
||
id="galaxyCollapseAll"
|
||
title="کولاپس همه محصولات"
|
||
>
|
||
⊟ کولاپس همه
|
||
</button>
|
||
<button
|
||
class="btn btn-sm"
|
||
id="galaxyExpandAll"
|
||
title="باز کردن همه محصولات"
|
||
>
|
||
⊞ باز کردن همه
|
||
</button>
|
||
<button class="btn btn-sm" id="galaxyResetView">🎯 بازنشانی</button>
|
||
<button class="btn btn-sm" id="galaxyExportPNG">📷 PNG</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body galaxy-modal-body">
|
||
<div id="galaxyChartContainer" class="galaxy-chart-container"></div>
|
||
<div class="galaxy-search-results" id="galaxySearchResults"></div>
|
||
<div class="galaxy-legend" id="galaxyLegend"></div>
|
||
<div class="galaxy-info-panel" id="galaxyInfoPanel">
|
||
<button
|
||
class="modal-close"
|
||
id="galaxyInfoClose"
|
||
style="
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 8px;
|
||
width: 22px;
|
||
height: 22px;
|
||
font-size: 12px;
|
||
"
|
||
>
|
||
✕
|
||
</button>
|
||
<div id="galaxyInfoContent"></div>
|
||
</div>
|
||
<div class="galaxy-zoom-indicator" id="galaxyZoomIndicator">
|
||
🔍 100%
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="galaxy-stats" id="galaxyStats"></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ✅ Executive Dashboard Modal (COMPLETED) -->
|
||
<div class="modal-overlay" id="executiveDashboardModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">📊 داشبورد مدیریتی</div>
|
||
<div class="galaxy-toolbar">
|
||
<button class="btn btn-sm" id="dashboardRefresh">
|
||
🔄 بهروزرسانی
|
||
</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="dashboardContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="dashboard-footer-stats" id="dashboardFooterStats"></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependency Analysis Modal -->
|
||
<div class="modal-overlay" id="dependencyAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔗 تحلیل وابستگیها</div>
|
||
<div class="galaxy-toolbar">
|
||
<button class="btn btn-sm" id="depAnalysisRefresh">🔄</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div
|
||
id="dependencyAnalysisContainer"
|
||
class="dashboard-container"
|
||
></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="dashboard-footer-stats" id="depAnalysisFooterStats"></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Deployment Analysis Modal -->
|
||
<div class="modal-overlay" id="deploymentAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🚀 تحلیل استقرار</div>
|
||
<div class="galaxy-toolbar">
|
||
<button class="btn btn-sm" id="deployAnalysisRefresh">🔄</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div
|
||
id="deploymentAnalysisContainer"
|
||
class="dashboard-container"
|
||
></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div
|
||
class="dashboard-footer-stats"
|
||
id="deployAnalysisFooterStats"
|
||
></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Version Analysis Modal -->
|
||
<div class="modal-overlay" id="versionAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔢 تحلیل نسخه</div>
|
||
<div class="galaxy-toolbar">
|
||
<button class="btn btn-sm" id="verAnalysisRefresh">🔄</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="versionAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="dashboard-footer-stats" id="verAnalysisFooterStats"></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Risk Analysis Modal -->
|
||
<div class="modal-overlay" id="riskAnalysisModal">
|
||
<div class="modal modal-galaxy">
|
||
<div class="modal-header">
|
||
<div class="modal-title">⚠️ تحلیل ریسک</div>
|
||
<div class="galaxy-toolbar">
|
||
<button class="btn btn-sm" id="riskAnalysisRefresh">🔄</button>
|
||
</div>
|
||
<button class="modal-close" data-close-modal>✕</button>
|
||
</div>
|
||
<div class="modal-body dashboard-modal-body">
|
||
<div id="riskAnalysisContainer" class="dashboard-container"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div
|
||
class="dashboard-footer-stats"
|
||
id="riskAnalysisFooterStats"
|
||
></div>
|
||
<button class="btn btn-ghost" data-close-modal>بستن</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
<div class="galaxy-tooltip" id="galaxyTooltip"></div>
|
||
<div class="d3-tooltip" id="d3Tooltip"></div>
|
||
|
||
<script>
|
||
/* ==================== DATA (NEW STRUCTURE) ==================== */
|
||
const projectData = {
|
||
projectId: "550e8400-e29b-41d4-a716-446655440000",
|
||
projectName: "xReleaseAssistant",
|
||
projectTitle: "دستیار انتشار",
|
||
description: "مدیریت نسخهها و نیازمندیهای محصولات",
|
||
version: "1.1.0",
|
||
developer: "هادی خزاعی اصل",
|
||
company: "رایان پرداز کاوش",
|
||
metadata: {
|
||
createdAt: "2026-09-13T00:00:00Z",
|
||
updatedAt: "2026-09-14T00:00:00Z",
|
||
tags: ["release-management"],
|
||
customFields: {},
|
||
},
|
||
webhook: { url: "", clientId: "", enabled: false },
|
||
toolGroups: [
|
||
{
|
||
id: "product-management",
|
||
title: "پرونده",
|
||
icon: "📁",
|
||
description: "مدیریت چرخه حیات محصول",
|
||
tools: [
|
||
{
|
||
id: "clear-all",
|
||
title: "پاکسازی",
|
||
icon: "🗑️",
|
||
action: "clearAll",
|
||
description: "پاکسازی کامل",
|
||
},
|
||
{
|
||
id: "save-product",
|
||
title: "ذخیره",
|
||
icon: "💾",
|
||
action: "saveProduct",
|
||
description: "دانلود دادهها",
|
||
},
|
||
{
|
||
id: "load-product",
|
||
title: "بارگذاری",
|
||
icon: "📂",
|
||
action: "loadProduct",
|
||
description: "بارگذاری از فایل",
|
||
},
|
||
{
|
||
id: "product-settings",
|
||
title: "تنظیمات",
|
||
icon: "⚙️",
|
||
action: "openProductSettings",
|
||
description: "پیکربندی",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "reports",
|
||
title: "گزارشها",
|
||
icon: "📊",
|
||
description: "تحلیل و گزارشگیری",
|
||
tools: [
|
||
{
|
||
id: "executive-dashboard",
|
||
title: "داشبورد مدیریتی",
|
||
icon: "📊",
|
||
action: "showExecutiveDashboard",
|
||
description: "نمای کلی با نمودارها",
|
||
},
|
||
{
|
||
id: "dependency-analysis",
|
||
title: "تحلیل وابستگیها",
|
||
icon: "🔗",
|
||
action: "showDependencyAnalysis",
|
||
description: "تحلیل وابستگیها",
|
||
},
|
||
{
|
||
id: "deployment-analysis",
|
||
title: "تحلیل استقرار",
|
||
icon: "🚀",
|
||
action: "showDeploymentAnalysis",
|
||
description: "تحلیل استقرارها",
|
||
},
|
||
{
|
||
id: "version-analysis",
|
||
title: "تحلیل نسخه",
|
||
icon: "🔢",
|
||
action: "showVersionAnalysis",
|
||
description: "تحلیل نسخهها",
|
||
},
|
||
{
|
||
id: "risk-analysis",
|
||
title: "تحلیل ریسک",
|
||
icon: "⚠️",
|
||
action: "showRiskAnalysis",
|
||
description: "تحلیل ریسکها",
|
||
},
|
||
{
|
||
id: "galaxy-chart",
|
||
title: "نمودار کهکشانی",
|
||
icon: "🌌",
|
||
action: "showGalaxyChart",
|
||
description: "نمودار کهکشانی",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
appData: {
|
||
environments: [
|
||
{
|
||
id: "env-prod-001",
|
||
name: "Production",
|
||
description: "محیط عملیاتی اصلی - در دسترس کاربران نهایی",
|
||
backgroundColor: "#38a169",
|
||
textColor: "#ffffff",
|
||
},
|
||
{
|
||
id: "env-stage-002",
|
||
name: "Staging",
|
||
description: "محیط پیش از تولید - شبیهسازی Production",
|
||
backgroundColor: "#dd6b20",
|
||
textColor: "#ffffff",
|
||
},
|
||
{
|
||
id: "env-dev-003",
|
||
name: "Development",
|
||
description: "محیط توسعه - برای تیم فنی",
|
||
backgroundColor: "#3182ce",
|
||
textColor: "#ffffff",
|
||
},
|
||
{
|
||
id: "env-qa-004",
|
||
name: "QA",
|
||
description: "محیط تست کیفیت - کنترل کیفیت خودکار و دستی",
|
||
backgroundColor: "#805ad5",
|
||
textColor: "#ffffff",
|
||
},
|
||
{
|
||
id: "env-uat-005",
|
||
name: "UAT",
|
||
description: "تست پذیرش کاربر - تأیید کارفرما",
|
||
backgroundColor: "#d69e2e",
|
||
textColor: "#ffffff",
|
||
},
|
||
{
|
||
id: "env-dr-006",
|
||
name: "Disaster Recovery",
|
||
description: "محیط بازیابی پس از بحران - پشتیبان Production",
|
||
backgroundColor: "#e53e3e",
|
||
textColor: "#ffffff",
|
||
},
|
||
],
|
||
products: [
|
||
{
|
||
id: "prod-ecommerce",
|
||
title: "سامانه فروشگاه آنلاین دیجیمارکت",
|
||
description:
|
||
"پلتفرم جامع تجارت الکترونیک با قابلیتهای چندفروشندگی",
|
||
metadata: {
|
||
createdAt: "2026-01-15T08:00:00Z",
|
||
owner: "تیم توسعه تجارت الکترونیک",
|
||
customFields: { industry: "E-commerce", priority: "critical" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-ecom-api",
|
||
title: "E-commerce Core API",
|
||
description: "سرویس بکاند اصلی فروشگاه",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/ecommerce/core-api",
|
||
metadata: {
|
||
framework: ".NET 8",
|
||
language: "C#",
|
||
team: "Backend Team A",
|
||
},
|
||
dependencies: ["proj-payment-gateway", "proj-inventory-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.5.2",
|
||
deploymentDate: "2026-09-10T14:30:00Z",
|
||
releaseNotes:
|
||
"رفع باگ پردازش سفارش و بهبود عملکرد کوئریها",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-gateway",
|
||
deploymentId: "dep-payment-prod",
|
||
},
|
||
{
|
||
projectId: "proj-inventory-svc",
|
||
deploymentId: "dep-inventory-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-ecom-api-stage",
|
||
environmentId: "env-stage-002",
|
||
version: "3.6.0-rc.1",
|
||
deploymentDate: "2026-09-14T09:00:00Z",
|
||
releaseNotes: "افزودن قابلیت چندزبانه و بهینهسازی کش",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-ecom-api-dev",
|
||
environmentId: "env-dev-003",
|
||
version: "3.7.0-dev.12",
|
||
deploymentDate: "2026-09-15T16:00:00Z",
|
||
releaseNotes: "پیادهسازی اولیه ماژول تخفیف پیشرفته",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-ecom-web",
|
||
title: "E-commerce Web Frontend",
|
||
description: "اپلیکیشن وب فروشگاه با Angular",
|
||
type: "frontend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/ecommerce/web-app",
|
||
metadata: { framework: "Angular 18", language: "TypeScript" },
|
||
dependencies: ["proj-ecom-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.2.1",
|
||
deploymentDate: "2026-09-11T10:00:00Z",
|
||
releaseNotes:
|
||
"بهبود UX صفحه پرداخت و افزودن فیلتر پیشرفته",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-ecom-api",
|
||
deploymentId: "dep-ecom-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-ecom-web-stage",
|
||
environmentId: "env-stage-002",
|
||
version: "4.3.0-rc.1",
|
||
deploymentDate: "2026-09-13T11:30:00Z",
|
||
releaseNotes: "پیادهسازی Dark Mode و بهینهسازی SEO",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-ecom-mobile",
|
||
title: "E-commerce Mobile App",
|
||
description: "اپلیکیشن موبایل فروشگاه (iOS/Android)",
|
||
type: "mobile",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/ecommerce/mobile-app",
|
||
metadata: { framework: "Flutter 3.22", language: "Dart" },
|
||
dependencies: ["proj-ecom-api", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.8.0",
|
||
deploymentDate: "2026-09-08T08:00:00Z",
|
||
releaseNotes:
|
||
"انتشار در استورها با قابلیت Push Notification",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-ecom-api",
|
||
deploymentId: "dep-ecom-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-ecom-db",
|
||
title: "E-commerce Database",
|
||
description: "پایگاه داده اصلی فروشگاه",
|
||
type: "database",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/ecommerce/db-migrations",
|
||
metadata: { framework: "SQL Server 2022", language: "T-SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "5.1.0",
|
||
deploymentDate: "2026-09-05T02:00:00Z",
|
||
releaseNotes:
|
||
"افزودن ایندکسهای جدید و بهینهسازی جداول سفارش",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-ecom-db-dr",
|
||
environmentId: "env-dr-006",
|
||
version: "5.1.0",
|
||
deploymentDate: "2026-09-05T03:00:00Z",
|
||
releaseNotes:
|
||
"همگامسازی با Production برای Disaster Recovery",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-ecom-db",
|
||
deploymentId: "dep-ecom-db-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-ecom-search",
|
||
title: "E-commerce Search Service",
|
||
description: "سرویس جستجوی پیشرفته مبتنی بر Elasticsearch",
|
||
type: "service",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/ecommerce/search-svc",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-ecom-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-search-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.9.3",
|
||
deploymentDate: "2026-09-09T13:00:00Z",
|
||
releaseNotes:
|
||
"بهبود الگوریتم جستجوی فازی و افزودن فیلتر قیمت",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-ecom-db",
|
||
deploymentId: "dep-ecom-db-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-ecom-infra",
|
||
title: "E-commerce Infrastructure",
|
||
description: "زیرساخت Kubernetes و CI/CD",
|
||
type: "infrastructure",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/ecommerce/infra",
|
||
metadata: {
|
||
framework: "Kubernetes 1.29",
|
||
language: "YAML/Helm",
|
||
},
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-ecom-infra-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.1.0",
|
||
deploymentDate: "2026-09-01T06:00:00Z",
|
||
releaseNotes:
|
||
"بهروزرسانی Helm Charts و افزودن Auto-scaling",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-banking",
|
||
title: "پلتفرم بانکداری دیجیتال پارسیانبانک",
|
||
description: "سامانه جامع بانکداری دیجیتال با قابلیتهای نئوبانک",
|
||
metadata: {
|
||
createdAt: "2026-02-01T08:00:00Z",
|
||
owner: "تیم توسعه بانکداری دیجیتال",
|
||
customFields: { industry: "FinTech", priority: "critical" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-bank-core",
|
||
title: "Banking Core Engine",
|
||
description: "موتور اصلی پردازش تراکنشهای بانکی",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/banking/core-engine",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-payment-gateway", "proj-auth-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-bank-core-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "6.2.0",
|
||
deploymentDate: "2026-09-12T01:00:00Z",
|
||
releaseNotes:
|
||
"افزودن پشتیبانی از تراکنشهای بینالمللی و بهبود امنیت",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-gateway",
|
||
deploymentId: "dep-payment-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-bank-core-uat",
|
||
environmentId: "env-uat-005",
|
||
version: "6.3.0-rc.2",
|
||
deploymentDate: "2026-09-14T10:00:00Z",
|
||
releaseNotes: "تست پذیرش ماژول وامدهی خرد",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-bank-web",
|
||
title: "Banking Web Portal",
|
||
description: "پرتال وب بانکداری اینترنتی",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/banking/web-portal",
|
||
metadata: { framework: "Angular 18", language: "TypeScript" },
|
||
dependencies: ["proj-bank-core"],
|
||
deployments: [
|
||
{
|
||
id: "dep-bank-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "5.1.4",
|
||
deploymentDate: "2026-09-13T08:30:00Z",
|
||
releaseNotes:
|
||
"بازطراحی داشبورد کاربر و افزودن گزارشهای مالی",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-bank-core",
|
||
deploymentId: "dep-bank-core-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-bank-mobile",
|
||
title: "Banking Mobile App",
|
||
description: "اپلیکیشن موبایل بانکداری همراه",
|
||
type: "mobile",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/banking/mobile-app",
|
||
metadata: {
|
||
framework: "Kotlin Multiplatform",
|
||
language: "Kotlin",
|
||
},
|
||
dependencies: ["proj-bank-core", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-bank-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.0.2",
|
||
deploymentDate: "2026-09-07T09:00:00Z",
|
||
releaseNotes: "افزودن ورود با Biometric و کارت به کارت",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-bank-core",
|
||
deploymentId: "dep-bank-core-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-bank-mobile-qa",
|
||
environmentId: "env-qa-004",
|
||
version: "4.1.0-beta.3",
|
||
deploymentDate: "2026-09-15T14:00:00Z",
|
||
releaseNotes: "تست قابلیت پرداخت با QR Code",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-bank-db",
|
||
title: "Banking Database Cluster",
|
||
description: "خوشه پایگاه داده بانکی با قابلیت HA",
|
||
type: "database",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/banking/db-cluster",
|
||
metadata: { framework: "Oracle 19c", language: "PL/SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-bank-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "8.0.1",
|
||
deploymentDate: "2026-09-02T03:00:00Z",
|
||
releaseNotes:
|
||
"ارتقای ساختار جداول تراکنش و افزودن Partitioning",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-bank-db-dr",
|
||
environmentId: "env-dr-006",
|
||
version: "8.0.1",
|
||
deploymentDate: "2026-09-02T04:00:00Z",
|
||
releaseNotes: "همگامسازی Active-Passive با Production",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-bank-fraud",
|
||
title: "Fraud Detection Service",
|
||
description: "سرویس هوشمند تشخیص تقلب با ML",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/banking/fraud-svc",
|
||
metadata: {
|
||
framework: "Python 3.12 + FastAPI",
|
||
language: "Python",
|
||
},
|
||
dependencies: ["proj-bank-core", "proj-bank-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-bank-fraud-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.4.0",
|
||
deploymentDate: "2026-09-11T15:00:00Z",
|
||
releaseNotes:
|
||
"بهروزرسانی مدل ML و کاهش نرخ False Positive",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-bank-core",
|
||
deploymentId: "dep-bank-core-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-healthcare",
|
||
title: "سامانه سلامت و نوبتدهی سلامتیار",
|
||
description: "پلتفرم جامع نوبتدهی و پرونده الکترونیک سلامت",
|
||
metadata: {
|
||
createdAt: "2026-02-20T08:00:00Z",
|
||
owner: "تیم توسعه سلامت دیجیتال",
|
||
customFields: { industry: "Healthcare", priority: "high" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-health-api",
|
||
title: "Healthcare Core API",
|
||
description: "سرویس بکاند سامانه سلامت",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/healthcare/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-auth-svc", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-health-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.7.1",
|
||
deploymentDate: "2026-09-10T11:00:00Z",
|
||
releaseNotes: "افزودن ماژول نسخهنویسی الکترونیک",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-health-api-stage",
|
||
environmentId: "env-stage-002",
|
||
version: "2.8.0-rc.1",
|
||
deploymentDate: "2026-09-14T13:00:00Z",
|
||
releaseNotes: "یکپارچگی با سامانه بیمه تأمین اجتماعی",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-health-web",
|
||
title: "Healthcare Patient Portal",
|
||
description: "پرتال بیماران برای نوبتدهی و مشاهده پرونده",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/healthcare/patient-portal",
|
||
metadata: { framework: "React 18", language: "TypeScript" },
|
||
dependencies: ["proj-health-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-health-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.1.0",
|
||
deploymentDate: "2026-09-12T09:30:00Z",
|
||
releaseNotes:
|
||
"بهبود فرآیند رزرو نوبت و افزودن یادآور خودکار",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-health-api",
|
||
deploymentId: "dep-health-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-health-doc",
|
||
title: "Doctor Dashboard",
|
||
description: "داشبورد پزشکان برای مدیریت بیماران",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/healthcare/doctor-dashboard",
|
||
metadata: { framework: "Angular 18", language: "TypeScript" },
|
||
dependencies: ["proj-health-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-health-doc-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.3.2",
|
||
deploymentDate: "2026-09-09T10:00:00Z",
|
||
releaseNotes: "افزودن تقویم هوشمند و مدیریت نسخهها",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-health-api",
|
||
deploymentId: "dep-health-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-health-db",
|
||
title: "Healthcare Database",
|
||
description: "پایگاه داده پروندههای الکترونیک سلامت",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/healthcare/db",
|
||
metadata: { framework: "PostgreSQL 16", language: "SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-health-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.0.0",
|
||
deploymentDate: "2026-09-03T02:30:00Z",
|
||
releaseNotes: "مهاجرت به ساختار جدید پرونده الکترونیک",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-health-mobile",
|
||
title: "Healthcare Mobile App",
|
||
description: "اپلیکیشن موبایل بیماران",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/healthcare/mobile",
|
||
metadata: {
|
||
framework: "React Native 0.74",
|
||
language: "TypeScript",
|
||
},
|
||
dependencies: ["proj-health-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-health-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.5.0",
|
||
deploymentDate: "2026-09-06T08:00:00Z",
|
||
releaseNotes: "انتشار نسخه اولیه با قابلیت نوبتدهی",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-health-api",
|
||
deploymentId: "dep-health-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-edtech",
|
||
title: "پلتفرم آموزش آنلاین دانشیار",
|
||
description:
|
||
"سامانه LMS با قابلیت برگزاری کلاس زنده و محتوای تعاملی",
|
||
metadata: {
|
||
createdAt: "2026-03-05T08:00:00Z",
|
||
owner: "تیم توسعه EdTech",
|
||
customFields: { industry: "Education", priority: "high" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-edu-api",
|
||
title: "LMS Core API",
|
||
description: "سرویس بکاند سامانه آموزش",
|
||
type: "backend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/edtech/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-auth-svc", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-edu-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.2.1",
|
||
deploymentDate: "2026-09-11T12:00:00Z",
|
||
releaseNotes: "افزودن سیستم آزمون آنلاین و تصحیح خودکار",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-edu-web",
|
||
title: "LMS Web Platform",
|
||
description: "پلتفرم وب آموزش برای دانشجویان و اساتید",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/edtech/web-platform",
|
||
metadata: { framework: "Next.js 14", language: "TypeScript" },
|
||
dependencies: ["proj-edu-api", "proj-video-streaming"],
|
||
deployments: [
|
||
{
|
||
id: "dep-edu-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.0.3",
|
||
deploymentDate: "2026-09-13T10:00:00Z",
|
||
releaseNotes:
|
||
"بهبود پخشکننده ویدیو و افزودن زیرنویس خودکار",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-edu-api",
|
||
deploymentId: "dep-edu-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-edu-video",
|
||
title: "Video Streaming Service",
|
||
description: "سرویس استریم ویدیوی کلاسهای زنده",
|
||
type: "service",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/edtech/video-streaming",
|
||
metadata: { framework: "Node.js 20", language: "TypeScript" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-edu-video-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.1.0",
|
||
deploymentDate: "2026-09-08T14:00:00Z",
|
||
releaseNotes: "پشتیبانی از کیفیت 4K و کاهش تأخیر استریم",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-edu-db",
|
||
title: "LMS Database",
|
||
description: "پایگاه داده دورهها و کاربران",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/edtech/db",
|
||
metadata: {
|
||
framework: "MongoDB 7",
|
||
language: "MongoDB Query",
|
||
},
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-edu-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.0.0",
|
||
deploymentDate: "2026-09-04T03:00:00Z",
|
||
releaseNotes: "مهاجرت به ساختار شاردینگ برای مقیاسپذیری",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-edu-mobile",
|
||
title: "LMS Mobile App",
|
||
description: "اپلیکیشن موبایل دانشجویان",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/edtech/mobile",
|
||
metadata: { framework: "Flutter 3.22", language: "Dart" },
|
||
dependencies: ["proj-edu-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-edu-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.0.1",
|
||
deploymentDate: "2026-09-07T09:30:00Z",
|
||
releaseNotes: "قابلیت دانلود آفلاین ویدیوها",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-edu-api",
|
||
deploymentId: "dep-edu-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-transport",
|
||
title: "سامانه حملونقل هوشمند ترافیکیار",
|
||
description: "پلتفرم مدیریت ناوگان و مسیریابی هوشمند",
|
||
metadata: {
|
||
createdAt: "2026-03-18T08:00:00Z",
|
||
owner: "تیم توسعه Smart City",
|
||
customFields: { industry: "Transportation", priority: "high" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-trans-api",
|
||
title: "Transport Core API",
|
||
description: "سرویس بکاند سامانه حملونقل",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/transport/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-maps-svc", "proj-auth-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-trans-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.5.0",
|
||
deploymentDate: "2026-09-10T13:00:00Z",
|
||
releaseNotes:
|
||
"افزودن الگوریتم مسیریابی پیشرفته با ترافیک زنده",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-trans-web",
|
||
title: "Fleet Management Dashboard",
|
||
description: "داشبورد مدیریت ناوگان",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/transport/fleet-dashboard",
|
||
metadata: { framework: "Vue.js 3", language: "TypeScript" },
|
||
dependencies: ["proj-trans-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-trans-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.1.2",
|
||
deploymentDate: "2026-09-12T11:00:00Z",
|
||
releaseNotes:
|
||
"نمایش زنده ناوگان روی نقشه و گزارشهای عملکرد",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-trans-api",
|
||
deploymentId: "dep-trans-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-maps-svc",
|
||
title: "Maps & Geolocation Service",
|
||
description: "سرویس نقشه و موقعیتیابی - سرویس مشترک",
|
||
type: "service",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/transport/maps-svc",
|
||
metadata: { framework: "Go 1.22", language: "Go" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-trans-maps-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.8.0",
|
||
deploymentDate: "2026-09-09T15:00:00Z",
|
||
releaseNotes: "بهینهسازی reverse geocoding و افزودن POI",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-trans-mobile",
|
||
title: "Driver Mobile App",
|
||
description: "اپلیکیشن رانندگان",
|
||
type: "mobile",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/transport/driver-app",
|
||
metadata: { framework: "Kotlin", language: "Kotlin" },
|
||
dependencies: ["proj-trans-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-trans-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.2.1",
|
||
deploymentDate: "2026-09-08T08:30:00Z",
|
||
releaseNotes: "بهبود مصرف باتری و دقت GPS",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-trans-api",
|
||
deploymentId: "dep-trans-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-trans-db",
|
||
title: "Transport Database",
|
||
description: "پایگاه داده سفرها و ناوگان",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/transport/db",
|
||
metadata: {
|
||
framework: "PostgreSQL 16 + PostGIS",
|
||
language: "SQL",
|
||
},
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-trans-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.2.0",
|
||
deploymentDate: "2026-09-03T02:00:00Z",
|
||
releaseNotes:
|
||
"افزودن جداول مکانی و بهینهسازی کوئریهای برداری",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-realestate",
|
||
title: "پلتفرم املاک و مستغلات خانهیار",
|
||
description: "سامانه جامع خرید، فروش و اجاره املاک",
|
||
metadata: {
|
||
createdAt: "2026-04-02T08:00:00Z",
|
||
owner: "تیم توسعه PropTech",
|
||
customFields: { industry: "Real Estate", priority: "medium" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-re-api",
|
||
title: "Real Estate Core API",
|
||
description: "سرویس بکاند پلتفرم املاک",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/realestate/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-maps-svc", "proj-auth-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-re-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.1.3",
|
||
deploymentDate: "2026-09-11T10:30:00Z",
|
||
releaseNotes: "افزودن تور مجازی ۳۶۰ درجه و فیلتر پیشرفته",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-maps-svc",
|
||
deploymentId: "dep-trans-maps-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-re-web",
|
||
title: "Real Estate Web Portal",
|
||
description: "پرتال وب جستجوی املاک",
|
||
type: "frontend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/realestate/web-portal",
|
||
metadata: { framework: "Next.js 14", language: "TypeScript" },
|
||
dependencies: ["proj-re-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-re-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.0.2",
|
||
deploymentDate: "2026-09-13T09:00:00Z",
|
||
releaseNotes: "نمایش املاک روی نقشه و مقایسه چندگانه",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-re-api",
|
||
deploymentId: "dep-re-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-re-mobile",
|
||
title: "Real Estate Mobile App",
|
||
description: "اپلیکیشن موبایل جستجوی ملک",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/realestate/mobile",
|
||
metadata: { framework: "Swift", language: "Swift" },
|
||
dependencies: ["proj-re-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-re-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.7.0",
|
||
deploymentDate: "2026-09-06T08:00:00Z",
|
||
releaseNotes: "نسخه iOS با قابلیت AR برای مشاهده ملک",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-re-api",
|
||
deploymentId: "dep-re-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-re-db",
|
||
title: "Real Estate Database",
|
||
description: "پایگاه داده املاک و کاربران",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/realestate/db",
|
||
metadata: { framework: "SQL Server 2022", language: "T-SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-re-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.5.0",
|
||
deploymentDate: "2026-09-04T03:00:00Z",
|
||
releaseNotes: "بهینهسازی ایندکسهای مکانی",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-travel",
|
||
title: "سامانه گردشگری و رزرو سفر سفریار",
|
||
description: "پلتفرم رزرو بلیط، هتل و تور",
|
||
metadata: {
|
||
createdAt: "2026-04-15T08:00:00Z",
|
||
owner: "تیم توسعه TravelTech",
|
||
customFields: { industry: "Tourism", priority: "high" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-travel-api",
|
||
title: "Travel Booking API",
|
||
description: "سرویس بکاند رزرواسیون",
|
||
type: "backend",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/travel/booking-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: [
|
||
"proj-payment-gateway",
|
||
"proj-notification-svc",
|
||
],
|
||
deployments: [
|
||
{
|
||
id: "dep-travel-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.4.1",
|
||
deploymentDate: "2026-09-12T14:00:00Z",
|
||
releaseNotes: "یکپارچگی با GDS جهانی و افزودن رزرو هتل",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-gateway",
|
||
deploymentId: "dep-payment-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-travel-web",
|
||
title: "Travel Booking Portal",
|
||
description: "پرتال وب رزرو سفر",
|
||
type: "frontend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/travel/web-portal",
|
||
metadata: { framework: "React 18", language: "TypeScript" },
|
||
dependencies: ["proj-travel-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-travel-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.1.0",
|
||
deploymentDate: "2026-09-13T11:30:00Z",
|
||
releaseNotes:
|
||
"بازطراحی فرآیند رزرو و افزودن پیشنهاد هوشمند",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-travel-api",
|
||
deploymentId: "dep-travel-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-travel-mobile",
|
||
title: "Travel Mobile App",
|
||
description: "اپلیکیشن موبایل رزرو سفر",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/travel/mobile",
|
||
metadata: { framework: "Flutter 3.22", language: "Dart" },
|
||
dependencies: ["proj-travel-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-travel-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.6.2",
|
||
deploymentDate: "2026-09-07T09:00:00Z",
|
||
releaseNotes: "افزودن کارت پرواز دیجیتال و مدیریت رزرو",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-travel-api",
|
||
deploymentId: "dep-travel-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-travel-db",
|
||
title: "Travel Database",
|
||
description: "پایگاه داده رزروها و کاربران",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/travel/db",
|
||
metadata: { framework: "PostgreSQL 16", language: "SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-travel-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.1.0",
|
||
deploymentDate: "2026-09-03T02:30:00Z",
|
||
releaseNotes: "افزودن جداول رزروهای بینالمللی",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-hr",
|
||
title: "پلتفرم مدیریت منابع انسانی کاریار",
|
||
description:
|
||
"سامانه جامع HR شامل حقوق و دستمزد، حضور و غیاب و ارزیابی",
|
||
metadata: {
|
||
createdAt: "2026-05-01T08:00:00Z",
|
||
owner: "تیم توسعه HR Tech",
|
||
customFields: {
|
||
industry: "Human Resources",
|
||
priority: "medium",
|
||
},
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-hr-api",
|
||
title: "HR Core API",
|
||
description: "سرویس بکاند سامانه HR",
|
||
type: "backend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/hr/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-auth-svc", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-hr-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.9.0",
|
||
deploymentDate: "2026-09-10T10:00:00Z",
|
||
releaseNotes: "ماژول جدید ارزیابی عملکرد ۳۶۰ درجه",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-hr-web",
|
||
title: "HR Web Portal",
|
||
description: "پرتال وب کارکنان و مدیران",
|
||
type: "frontend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/hr/web-portal",
|
||
metadata: { framework: "Angular 18", language: "TypeScript" },
|
||
dependencies: ["proj-hr-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-hr-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.3.1",
|
||
deploymentDate: "2026-09-12T09:00:00Z",
|
||
releaseNotes: "داشبورد مدیریتی جدید و گزارشهای حقوق",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-hr-api",
|
||
deploymentId: "dep-hr-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-hr-mobile",
|
||
title: "HR Mobile App",
|
||
description: "اپلیکیشن موبایل کارکنان",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/hr/mobile",
|
||
metadata: {
|
||
framework: "React Native 0.74",
|
||
language: "TypeScript",
|
||
},
|
||
dependencies: ["proj-hr-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-hr-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.8.0",
|
||
deploymentDate: "2026-09-06T08:30:00Z",
|
||
releaseNotes: "ثبت حضور با GPS و درخواست مرخصی",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-hr-api",
|
||
deploymentId: "dep-hr-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-hr-db",
|
||
title: "HR Database",
|
||
description: "پایگاه داده کارکنان و سوابق",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/hr/db",
|
||
metadata: { framework: "SQL Server 2022", language: "T-SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-hr-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.0.0",
|
||
deploymentDate: "2026-09-02T02:00:00Z",
|
||
releaseNotes: "ساختار جدید برای ارزیابی عملکرد",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-hr-payroll",
|
||
title: "Payroll Engine",
|
||
description: "موتور محاسبه حقوق و دستمزد",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/hr/payroll-engine",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-hr-api", "proj-hr-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-hr-payroll-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.2.1",
|
||
deploymentDate: "2026-09-09T11:00:00Z",
|
||
releaseNotes: "پشتیبانی از قوانین جدید مالیاتی ۱۴۰۵",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-hr-api",
|
||
deploymentId: "dep-hr-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-crm",
|
||
title: "سامانه CRM و فروش مشترییار",
|
||
description: "پلتفرم مدیریت ارتباط با مشتری و فروش",
|
||
metadata: {
|
||
createdAt: "2026-05-20T08:00:00Z",
|
||
owner: "تیم توسعه CRM",
|
||
customFields: { industry: "Sales & CRM", priority: "high" },
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-crm-api",
|
||
title: "CRM Core API",
|
||
description: "سرویس بکاند CRM",
|
||
type: "backend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/crm/core-api",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-auth-svc", "proj-notification-svc"],
|
||
deployments: [
|
||
{
|
||
id: "dep-crm-api-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.6.2",
|
||
deploymentDate: "2026-09-11T13:00:00Z",
|
||
releaseNotes:
|
||
"ماژول اتوماسیون بازاریابی و تحلیل رفتار مشتری",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-crm-web",
|
||
title: "CRM Web Dashboard",
|
||
description: "داشبورد وب تیم فروش",
|
||
type: "frontend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/crm/web-dashboard",
|
||
metadata: { framework: "React 18", language: "TypeScript" },
|
||
dependencies: ["proj-crm-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-crm-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.2.0",
|
||
deploymentDate: "2026-09-13T10:30:00Z",
|
||
releaseNotes: "نمای ۳۶۰ درجه مشتری و گزارشهای پیشرفته",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-crm-api",
|
||
deploymentId: "dep-crm-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-crm-mobile",
|
||
title: "CRM Mobile App",
|
||
description: "اپلیکیشن موبایل ویزیتورها",
|
||
type: "mobile",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/crm/mobile",
|
||
metadata: { framework: "Flutter 3.22", language: "Dart" },
|
||
dependencies: ["proj-crm-api"],
|
||
deployments: [
|
||
{
|
||
id: "dep-crm-mobile-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.1.3",
|
||
deploymentDate: "2026-09-07T09:00:00Z",
|
||
releaseNotes: "ثبت بازدید حضوری و آپلود فاکتور",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-crm-api",
|
||
deploymentId: "dep-crm-api-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-crm-db",
|
||
title: "CRM Database",
|
||
description: "پایگاه داده مشتریان و سرنخها",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/crm/db",
|
||
metadata: {
|
||
framework: "MongoDB 7",
|
||
language: "MongoDB Query",
|
||
},
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-crm-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.0.1",
|
||
deploymentDate: "2026-09-04T03:00:00Z",
|
||
releaseNotes:
|
||
"ساختار بهینه برای دادههای نیمهساختاریافته",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-crm-analytics",
|
||
title: "CRM Analytics Engine",
|
||
description: "موتور تحلیل و BI فروش",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/crm/analytics",
|
||
metadata: { framework: "Python 3.12", language: "Python" },
|
||
dependencies: ["proj-crm-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-crm-analytics-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.5.0",
|
||
deploymentDate: "2026-09-10T14:00:00Z",
|
||
releaseNotes:
|
||
"پیشبینی فروش با مدل ML و تحلیل چرخه عمر مشتری",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-crm-db",
|
||
deploymentId: "dep-crm-db-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "prod-payment",
|
||
title: "پلتفرم پرداخت و کیف پول پرداختیار",
|
||
description: "زیرساخت مشترک پرداخت برای تمام محصولات سازمان",
|
||
metadata: {
|
||
createdAt: "2026-01-05T08:00:00Z",
|
||
owner: "تیم توسعه Payment",
|
||
customFields: {
|
||
industry: "FinTech",
|
||
priority: "critical",
|
||
shared: true,
|
||
},
|
||
},
|
||
projects: [
|
||
{
|
||
id: "proj-payment-gateway",
|
||
title: "Payment Gateway Service",
|
||
description: "درگاه پرداخت یکپارچه - سرویس مشترک",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/payment/gateway",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-payment-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-payment-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "5.3.0",
|
||
deploymentDate: "2026-09-09T12:00:00Z",
|
||
releaseNotes:
|
||
"پشتیبانی از درگاههای جدید و توکنسازی کارت",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-db",
|
||
deploymentId: "dep-payment-db-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-payment-stage",
|
||
environmentId: "env-stage-002",
|
||
version: "5.4.0-rc.1",
|
||
deploymentDate: "2026-09-14T10:00:00Z",
|
||
releaseNotes: "تست یکپارچگی با شاپرک جدید",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-payment-qa",
|
||
environmentId: "env-qa-004",
|
||
version: "5.4.0-beta.2",
|
||
deploymentDate: "2026-09-15T15:00:00Z",
|
||
releaseNotes: "تستهای خودکار امنیتی و بار",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-payment-wallet",
|
||
title: "Digital Wallet Service",
|
||
description: "سرویس کیف پول الکترونیک",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/payment/wallet",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-payment-gateway", "proj-payment-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-payment-wallet-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "3.1.2",
|
||
deploymentDate: "2026-09-11T11:00:00Z",
|
||
releaseNotes: "افزودن قابلیت انتقال بین کیف پول و کشبک",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-gateway",
|
||
deploymentId: "dep-payment-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-payment-db",
|
||
title: "Payment Database",
|
||
description: "پایگاه داده تراکنشهای مالی",
|
||
type: "database",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/payment/db",
|
||
metadata: { framework: "Oracle 19c RAC", language: "PL/SQL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-payment-db-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "6.0.0",
|
||
deploymentDate: "2026-09-01T02:00:00Z",
|
||
releaseNotes:
|
||
"ارتقای ساختار تراکنشها با قابلیت Audit کامل",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-payment-db-dr",
|
||
environmentId: "env-dr-006",
|
||
version: "6.0.0",
|
||
deploymentDate: "2026-09-01T03:00:00Z",
|
||
releaseNotes: "همگامسازی Active-Active با Production",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-payment-web",
|
||
title: "Payment Management Portal",
|
||
description: "پرتال مدیریت تراکنشها برای اپراتورها",
|
||
type: "frontend",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/payment/portal",
|
||
metadata: { framework: "Angular 18", language: "TypeScript" },
|
||
dependencies: ["proj-payment-gateway"],
|
||
deployments: [
|
||
{
|
||
id: "dep-payment-web-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.8.1",
|
||
deploymentDate: "2026-09-12T10:00:00Z",
|
||
releaseNotes: "داشبورد تحلیل تراکنشها و مدیریت disputes",
|
||
requiredDeployments: [
|
||
{
|
||
projectId: "proj-payment-gateway",
|
||
deploymentId: "dep-payment-prod",
|
||
},
|
||
],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-auth-svc",
|
||
title: "Authentication & Authorization Service",
|
||
description: "سرویس مشترک احراز هویت و مجوزدهی",
|
||
type: "service",
|
||
repositoryUrl: "https://git.rayanpardaz.ir/shared/auth-svc",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-payment-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-auth-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "4.1.0",
|
||
deploymentDate: "2026-09-08T06:00:00Z",
|
||
releaseNotes: "افزودن MFA و پشتیبانی از OAuth 2.1",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
{
|
||
id: "dep-auth-stage",
|
||
environmentId: "env-stage-002",
|
||
version: "4.2.0-rc.1",
|
||
deploymentDate: "2026-09-14T08:00:00Z",
|
||
releaseNotes: "تست SSO با Azure AD",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-notification-svc",
|
||
title: "Notification Service",
|
||
description: "سرویس مشترک ارسال اعلان (SMS/Email/Push)",
|
||
type: "service",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/shared/notification-svc",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-notif-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.5.0",
|
||
deploymentDate: "2026-09-07T07:00:00Z",
|
||
releaseNotes: "افزودن کانال WhatsApp و بهینهسازی صف",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-inventory-svc",
|
||
title: "Inventory Management Service",
|
||
description: "سرویس مدیریت موجودی انبار",
|
||
type: "service",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/shared/inventory-svc",
|
||
metadata: { framework: ".NET 8", language: "C#" },
|
||
dependencies: ["proj-payment-db"],
|
||
deployments: [
|
||
{
|
||
id: "dep-inventory-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "2.0.3",
|
||
deploymentDate: "2026-09-06T10:00:00Z",
|
||
releaseNotes: "همگامسازی Real-time با انبارهای فیزیکی",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "proj-video-streaming",
|
||
title: "Video Streaming Infrastructure",
|
||
description: "زیرساخت استریم ویدیو (CDN + Transcoding)",
|
||
type: "infrastructure",
|
||
repositoryUrl:
|
||
"https://git.rayanpardaz.ir/shared/video-infra",
|
||
metadata: { framework: "Terraform + AWS", language: "HCL" },
|
||
dependencies: [],
|
||
deployments: [
|
||
{
|
||
id: "dep-video-infra-prod",
|
||
environmentId: "env-prod-001",
|
||
version: "1.3.0",
|
||
deploymentDate: "2026-09-05T05:00:00Z",
|
||
releaseNotes: "افزودن CDN منطقهای و Adaptive Bitrate",
|
||
requiredDeployments: [],
|
||
status: "active",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
const VALID_PROJECT_TYPES = [
|
||
{ value: "backend", label: "بکاند (Backend)", icon: "🖥️" },
|
||
{ value: "frontend", label: "فرانتاند (Frontend)", icon: "🌐" },
|
||
{ value: "service", label: "میکروسرویس (Service)", icon: "⚙️" },
|
||
{ value: "database", label: "پایگاه داده (Database)", icon: "🗄️" },
|
||
{ value: "mobile", label: "موبایل (Mobile)", icon: "📱" },
|
||
{
|
||
value: "infrastructure",
|
||
label: "زیرساخت (Infrastructure)",
|
||
icon: "🏗️",
|
||
},
|
||
];
|
||
/* ==================== HELPERS ==================== */
|
||
function validateAppDataStructure(d) {
|
||
return (
|
||
d &&
|
||
typeof d === "object" &&
|
||
Array.isArray(d.environments) &&
|
||
Array.isArray(d.products)
|
||
);
|
||
}
|
||
function populateProjectTypeDropdowns() {
|
||
["newProjectType", "editProjType"].forEach((id) => {
|
||
const s = document.getElementById(id);
|
||
if (!s) return;
|
||
const cv = s.value;
|
||
s.innerHTML =
|
||
'<option value="">انتخاب کنید...</option>' +
|
||
VALID_PROJECT_TYPES.map(
|
||
(t) => `<option value="${t.value}">${t.icon} ${t.label}</option>`,
|
||
).join("");
|
||
if (cv) s.value = cv;
|
||
});
|
||
}
|
||
function showToast(msg, type = "info", dur = 3000) {
|
||
const c = document.getElementById("toastContainer");
|
||
const t = document.createElement("div");
|
||
t.className = `toast ${type}`;
|
||
const ic = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" };
|
||
t.innerHTML = `<span>${ic[type] || "ℹ️"}</span><span>${msg}</span>`;
|
||
c.appendChild(t);
|
||
setTimeout(() => {
|
||
t.style.opacity = "0";
|
||
setTimeout(() => t.remove(), 300);
|
||
}, dur);
|
||
}
|
||
function showD3Tooltip(e, text) {
|
||
let tt = document.getElementById("d3Tooltip");
|
||
if (!tt) return;
|
||
tt.textContent = text;
|
||
tt.classList.add("visible");
|
||
tt.style.left = e.clientX + 12 + "px";
|
||
tt.style.top = e.clientY + 12 + "px";
|
||
}
|
||
function hideD3Tooltip() {
|
||
const tt = document.getElementById("d3Tooltip");
|
||
if (tt) tt.classList.remove("visible");
|
||
}
|
||
function refreshUI() {
|
||
ToolbarManager.updateStats();
|
||
BoardRenderer.render();
|
||
}
|
||
/* ==================== MODAL MANAGER ==================== */
|
||
const ModalManager = {
|
||
stack: [],
|
||
open(id) {
|
||
const m = document.getElementById(id);
|
||
if (!m) return;
|
||
this.stack = this.stack.filter((x) => x !== id);
|
||
this.stack.push(id);
|
||
this.stack.forEach((mid, i) => {
|
||
const el = document.getElementById(mid);
|
||
if (!el) return;
|
||
if (mid === id) el.classList.add("active");
|
||
el.style.zIndex = 1000 + i + 1;
|
||
});
|
||
},
|
||
close(id) {
|
||
const m = document.getElementById(id);
|
||
if (!m) return;
|
||
m.classList.remove("active");
|
||
m.style.zIndex = "";
|
||
this.stack = this.stack.filter((x) => x !== id);
|
||
this.stack.forEach((mid, i) => {
|
||
const el = document.getElementById(mid);
|
||
if (el) el.style.zIndex = 1000 + i + 1;
|
||
});
|
||
},
|
||
closeTop() {
|
||
if (this.stack.length > 0)
|
||
this.close(this.stack[this.stack.length - 1]);
|
||
},
|
||
init() {
|
||
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const m = e.target.closest(".modal-overlay");
|
||
if (m && m.id) this.close(m.id);
|
||
});
|
||
});
|
||
document.querySelectorAll(".modal-overlay").forEach((ov) => {
|
||
ov.addEventListener("click", (e) => {
|
||
if (e.target === ov && ov.id) this.close(ov.id);
|
||
});
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
this.closeTop();
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== TOOLBAR MANAGER ==================== */
|
||
const ToolbarManager = {
|
||
currentFilters: { query: "", typeFilter: "", envFilter: "" },
|
||
init() {
|
||
this.populateEnvironmentFilter();
|
||
this.bindEvents();
|
||
this.updateStats();
|
||
},
|
||
populateEnvironmentFilter() {
|
||
const s = document.getElementById("filterEnvironment");
|
||
if (!s) return;
|
||
s.innerHTML =
|
||
'<option value="">همه محیطها</option>' +
|
||
projectData.appData.environments
|
||
.map((e) => `<option value="${e.id}">${e.name}</option>`)
|
||
.join("");
|
||
},
|
||
bindEvents() {
|
||
const si = document.getElementById("toolbarSearch"),
|
||
ft = document.getElementById("filterType"),
|
||
fe = document.getElementById("filterEnvironment");
|
||
if (si)
|
||
si.addEventListener("input", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
if (ft)
|
||
ft.addEventListener("change", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
if (fe)
|
||
fe.addEventListener("change", () => {
|
||
this.applyFilters();
|
||
refreshUI();
|
||
});
|
||
},
|
||
applyFilters() {
|
||
this.currentFilters = {
|
||
query: (document.getElementById("toolbarSearch")?.value || "")
|
||
.trim()
|
||
.toLowerCase(),
|
||
typeFilter: document.getElementById("filterType")?.value || "",
|
||
envFilter:
|
||
document.getElementById("filterEnvironment")?.value || "",
|
||
};
|
||
this.updateStats();
|
||
},
|
||
updateStats() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0;
|
||
ps.forEach((p) => {
|
||
(p.projects || []).forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
});
|
||
});
|
||
const s = (id, v) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = v;
|
||
};
|
||
s("statTotal", ps.length + tp);
|
||
s("statProjects", ps.length);
|
||
s("statSubProjects", tp);
|
||
s("statDependencies", td);
|
||
},
|
||
shouldShowProduct(product) {
|
||
const f = this.currentFilters;
|
||
if (!f.query && !f.typeFilter && !f.envFilter) return true;
|
||
return (product.projects || []).some((pr) => {
|
||
const mq =
|
||
!f.query ||
|
||
pr.title.toLowerCase().includes(f.query) ||
|
||
product.title.toLowerCase().includes(f.query);
|
||
const mt = !f.typeFilter || pr.type === f.typeFilter;
|
||
const me =
|
||
!f.envFilter ||
|
||
(pr.deployments || [])
|
||
.filter((d) => d.status === "active")
|
||
.some((d) => d.environmentId === f.envFilter);
|
||
return mq && mt && me;
|
||
});
|
||
},
|
||
};
|
||
/* ==================== HISTORY MANAGER ==================== */
|
||
const HistoryManager = {
|
||
undoStack: [],
|
||
redoStack: [],
|
||
maxSize: 50,
|
||
isUndoRedoInProgress: false,
|
||
pushState() {
|
||
if (this.isUndoRedoInProgress) return;
|
||
this.undoStack.push(JSON.parse(JSON.stringify(projectData.appData)));
|
||
if (this.undoStack.length > this.maxSize) this.undoStack.shift();
|
||
this.redoStack = [];
|
||
this.updateButtons();
|
||
},
|
||
undo() {
|
||
if (this.undoStack.length === 0) {
|
||
showToast("هیچ تغییری نیست", "warning");
|
||
return;
|
||
}
|
||
this.redoStack.push(JSON.parse(JSON.stringify(projectData.appData)));
|
||
this.isUndoRedoInProgress = true;
|
||
projectData.appData = this.undoStack.pop();
|
||
this.isUndoRedoInProgress = false;
|
||
this.updateUI();
|
||
this.updateButtons();
|
||
showToast("↩️ بازگشت", "success");
|
||
},
|
||
redo() {
|
||
if (this.redoStack.length === 0) {
|
||
showToast("هیچ تغییری نیست", "warning");
|
||
return;
|
||
}
|
||
this.undoStack.push(JSON.parse(JSON.stringify(projectData.appData)));
|
||
this.isUndoRedoInProgress = true;
|
||
projectData.appData = this.redoStack.pop();
|
||
this.isUndoRedoInProgress = false;
|
||
this.updateUI();
|
||
this.updateButtons();
|
||
showToast("↪️ باز انجام", "success");
|
||
},
|
||
updateUI() {
|
||
refreshUI();
|
||
if (ProductManager.currentDetailProductId)
|
||
ProductManager.showDetail(ProductManager.currentDetailProductId);
|
||
},
|
||
updateButtons() {
|
||
const u = document.getElementById("undoBtn"),
|
||
r = document.getElementById("redoBtn");
|
||
if (u) u.disabled = this.undoStack.length === 0;
|
||
if (r) r.disabled = this.redoStack.length === 0;
|
||
},
|
||
init() {
|
||
this.pushState();
|
||
document
|
||
.getElementById("undoBtn")
|
||
.addEventListener("click", () => this.undo());
|
||
document
|
||
.getElementById("redoBtn")
|
||
.addEventListener("click", () => this.redo());
|
||
document.addEventListener("keydown", (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.undo();
|
||
}
|
||
if (
|
||
(e.ctrlKey || e.metaKey) &&
|
||
(e.key === "y" || (e.key === "z" && e.shiftKey))
|
||
) {
|
||
e.preventDefault();
|
||
this.redo();
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== SECTION COLLAPSE ==================== */
|
||
const SectionCollapseManager = {
|
||
storageKey: "boardSectionCollapseState",
|
||
getState() {
|
||
try {
|
||
const s = localStorage.getItem(this.storageKey);
|
||
return s
|
||
? JSON.parse(s)
|
||
: { dashboard: false, products: false, environments: false };
|
||
} catch {
|
||
return { dashboard: false, products: false, environments: false };
|
||
}
|
||
},
|
||
saveState(s) {
|
||
try {
|
||
localStorage.setItem(this.storageKey, JSON.stringify(s));
|
||
} catch (e) {}
|
||
},
|
||
toggle(id) {
|
||
const s = this.getState();
|
||
s[id] = !s[id];
|
||
this.saveState(s);
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec) sec.classList.toggle("collapsed", s[id]);
|
||
return s[id];
|
||
},
|
||
init() {
|
||
const s = this.getState();
|
||
Object.keys(s).forEach((id) => {
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec && s[id]) sec.classList.add("collapsed");
|
||
});
|
||
document
|
||
.getElementById("boardArea")
|
||
.addEventListener("click", (e) => {
|
||
const ht = e.target.closest(".board-header-title");
|
||
if (ht) {
|
||
const sec = ht.closest(".board-section");
|
||
if (sec) {
|
||
this.toggle(sec.id.replace("section-", ""));
|
||
}
|
||
}
|
||
});
|
||
},
|
||
};
|
||
/* ==================== DASHBOARD SECTION ==================== */
|
||
const DashboardSectionRenderer = {
|
||
getSectionHTML() {
|
||
const d = this.getData();
|
||
let h = `<div class="board-section" id="section-dashboard">`;
|
||
h += `<div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📊 داشبورد مدیریتی</div><div class="board-header-actions"><button class="btn btn-accent btn-sm" onclick="DashboardManager.open()">📊 مشاهده کامل</button></div></div>`;
|
||
h += `<div class="section-content">`;
|
||
|
||
// KPI Cards
|
||
h += '<div class="dashboard-kpi-row" style="margin-bottom:16px">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
|
||
h += "</div>";
|
||
|
||
// ✅ NEW: Charts Grid
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژهها</div><div class="dashboard-chart-body" id="mainDashTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="mainDashEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="mainDashStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژهها به تفکیک محصول</div><div class="dashboard-chart-body" id="mainDashProductChart"></div></div>';
|
||
h += "</div>";
|
||
|
||
h += "</div></div>";
|
||
return h;
|
||
},
|
||
|
||
getData() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0,
|
||
tdp = 0;
|
||
const tc = {},
|
||
edc = {},
|
||
ppc = [];
|
||
let pwd = 0,
|
||
pwod = 0;
|
||
|
||
ps.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
ppc.push({ title: prod.title, count: projects.length });
|
||
projects.forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
const active = (pr.deployments || []).filter(
|
||
(d) => d.status === "active",
|
||
);
|
||
tdp += active.length;
|
||
if (active.length > 0) pwd++;
|
||
else pwod++;
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
active.forEach((d) => {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
|
||
return {
|
||
products: ps.length,
|
||
projects: tp,
|
||
deps: td,
|
||
deploys: tdp,
|
||
environments: projectData.appData.environments.length,
|
||
tc,
|
||
edc,
|
||
ppc,
|
||
pwd,
|
||
pwod,
|
||
};
|
||
},
|
||
|
||
// ✅ NEW: Render charts after DOM is ready
|
||
renderCharts() {
|
||
const d = this.getData();
|
||
this.renderDonut(
|
||
"mainDashTypeChart",
|
||
Object.entries(d.tc).map(([k, v]) => ({
|
||
label: this.getTypeLabel(k),
|
||
value: v,
|
||
color: this.getTypeColor(k),
|
||
})),
|
||
);
|
||
this.renderBar(
|
||
"mainDashEnvChart",
|
||
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
|
||
"#38a169",
|
||
);
|
||
this.renderDonut("mainDashStatusChart", [
|
||
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
|
||
]);
|
||
this.renderBar(
|
||
"mainDashProductChart",
|
||
d.ppc.map((p) => ({
|
||
label:
|
||
p.title.length > 15
|
||
? p.title.substring(0, 15) + "..."
|
||
: p.title,
|
||
value: p.count,
|
||
})),
|
||
"#3182ce",
|
||
);
|
||
},
|
||
|
||
renderDonut(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 240,
|
||
hgt = 200,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 6);
|
||
const total = data.reduce((s, d) => s + d.value, 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => d.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "20px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.3em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.text("پروژه");
|
||
},
|
||
|
||
renderBar(cid, data, color) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 200,
|
||
m = { top: 20, right: 15, bottom: 40, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(data.map((d) => d.label))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (d) => d.value) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", (d) => x(d.label))
|
||
.attr("y", (d) => y(d.value))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (d) => ih - y(d.value))
|
||
.attr("fill", color)
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d.label}: ${d.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", color);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
|
||
.attr("y", (d) => y(d.value) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.value);
|
||
},
|
||
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
getTypeColor(t) {
|
||
const c = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
return c[t] || "#718096";
|
||
},
|
||
};
|
||
/* ==================== ENVIRONMENT RENDERER ==================== */
|
||
const EnvironmentRenderer = {
|
||
getSectionHTML() {
|
||
const envs = projectData.appData.environments || [];
|
||
let h = `<div class="board-section" id="section-environments"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>🌍 محیطهای عملیاتی (${envs.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="EnvironmentManager.openAddModal()">➕ محیط جدید</button></div></div><div class="section-content">`;
|
||
if (envs.length === 0) {
|
||
h +=
|
||
'<div class="board-empty"><div class="board-empty-icon">🌍</div><h2>هیچ محیطی نیست</h2></div>';
|
||
} else {
|
||
h += '<div class="env-card-grid">';
|
||
envs.forEach((env) => {
|
||
h += `<div class="env-card-item"><div class="env-card-preview" style="background-color:${env.backgroundColor || "#38a169"};color:${env.textColor || "#fff"}">${env.name}</div><div class="env-card-body"><div class="env-card-name">${env.name}</div><div class="env-card-desc">${env.description || ""}</div></div><div class="env-card-footer"><button class="btn btn-ghost btn-sm" onclick="EnvironmentManager.openEditModal('${env.id}')">✏️ ویرایش</button><button class="btn btn-danger btn-sm" onclick="EnvironmentManager.remove('${env.id}')">🗑️ حذف</button></div></div>`;
|
||
});
|
||
h += "</div>";
|
||
}
|
||
h += "</div></div>";
|
||
return h;
|
||
},
|
||
};
|
||
/* ==================== BOARD RENDERER ==================== */
|
||
const BoardRenderer = {
|
||
render() {
|
||
//
|
||
const board = document.getElementById("boardArea");
|
||
const products = projectData.appData.products || [];
|
||
const fp = products.filter((p) =>
|
||
ToolbarManager.shouldShowProduct(p),
|
||
);
|
||
let h = "";
|
||
h += DashboardSectionRenderer.getSectionHTML();
|
||
h += `<div class="board-section" id="section-products"><div class="board-header"><div class="board-header-title"><span class="collapse-icon">▼</span>📦 محصولات (${fp.length} از ${products.length})</div><div class="board-header-actions"><button class="btn btn-accent" onclick="ProductManager.openAddProductModal()">➕ محصول جدید</button></div></div><div class="section-content">`;
|
||
if (products.length === 0) {
|
||
h +=
|
||
'<div class="board-empty"><div class="board-empty-icon">📦</div><h2>هیچ محصولی نیست</h2></div>';
|
||
} else if (fp.length === 0) {
|
||
h += '<div class="no-results">🔍 نتیجهای یافت نشد</div>';
|
||
} else {
|
||
h += '<div class="projects-grid">';
|
||
fp.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
const tc = {};
|
||
projects.forEach((pr) => {
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
});
|
||
const td = projects.reduce(
|
||
(s, pr) => s + (pr.dependencies || []).length,
|
||
0,
|
||
);
|
||
const tdp = projects.reduce(
|
||
(s, pr) =>
|
||
s +
|
||
(pr.deployments || []).filter((d) => d.status === "active")
|
||
.length,
|
||
0,
|
||
);
|
||
h += `<div class="project-card"><div class="project-card-header"><div class="project-card-title-row"><span style="font-size:22px">📦</span><div class="project-card-title">${prod.title}</div></div><div class="project-card-desc">${prod.description || ""}</div></div><div class="project-card-body"><div class="project-stat-row"><span>🌿 پروژهها:</span><span class="project-stat-value">${projects.length}</span></div><div class="project-stat-row"><span>🔗 وابستگیها:</span><span class="project-stat-value">${td}</span></div><div class="project-stat-row"><span>🚀 استقرارها:</span><span class="project-stat-value">${tdp}</span></div>${
|
||
Object.keys(tc).length > 0
|
||
? `<div class="project-type-badges">${Object.entries(tc)
|
||
.map(([type, count]) => {
|
||
const ti = VALID_PROJECT_TYPES.find(
|
||
(t) => t.value === type,
|
||
) || { label: type };
|
||
return `<span class="type-badge type-${type}">${ti.label}: ${count}</span>`;
|
||
})
|
||
.join("")}</div>`
|
||
: ""
|
||
}</div><div class="project-card-footer"><div class="card-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.showDetail('${prod.id}')">👁️ جزئیات</button></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeProduct('${prod.id}')">🗑️</button></div></div>`;
|
||
});
|
||
h += "</div>";
|
||
}
|
||
h += "</div></div>";
|
||
h += EnvironmentRenderer.getSectionHTML();
|
||
board.innerHTML = h;
|
||
const st = SectionCollapseManager.getState();
|
||
Object.keys(st).forEach((id) => {
|
||
const sec = document.getElementById(`section-${id}`);
|
||
if (sec && st[id]) sec.classList.add("collapsed");
|
||
});
|
||
|
||
//
|
||
setTimeout(() => {
|
||
DashboardSectionRenderer.renderCharts();
|
||
}, 100);
|
||
},
|
||
};
|
||
/* ==================== ACTION REGISTRY ==================== */
|
||
const ActionRegistry = {
|
||
openProductSettings() {
|
||
openSettingsModal();
|
||
},
|
||
saveProduct() {
|
||
projectData.metadata.updatedAt = new Date().toISOString();
|
||
const js = JSON.stringify(projectData.appData, null, 2);
|
||
const b = new Blob([js], { type: "application/json;charset=utf-8" });
|
||
const u = URL.createObjectURL(b);
|
||
const a = document.createElement("a");
|
||
a.href = u;
|
||
a.download = `${projectData.projectName}_v${projectData.version}_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(u);
|
||
showToast("💾 ذخیره شد", "success");
|
||
},
|
||
loadProduct() {
|
||
const inp = document.createElement("input");
|
||
inp.type = "file";
|
||
inp.accept = ".json";
|
||
inp.addEventListener("change", (e) => {
|
||
const f = e.target.files[0];
|
||
if (!f) return;
|
||
const r = new FileReader();
|
||
r.onload = (ev) => {
|
||
try {
|
||
const ld = JSON.parse(ev.target.result);
|
||
if (!validateAppDataStructure(ld)) {
|
||
showToast("❌ ساختار نامعتبر", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
projectData.appData = ld;
|
||
updateProjectUI();
|
||
renderToolGroups();
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast("📂 بارگذاری شد", "success");
|
||
} catch (err) {
|
||
showToast("❌ خطا: " + err.message, "error");
|
||
}
|
||
};
|
||
r.readAsText(f);
|
||
});
|
||
inp.click();
|
||
},
|
||
clearAll() {
|
||
if (!confirm("⚠️ پاکسازی کامل؟ این عمل غیرقابل بازگشت است!")) return;
|
||
if (!confirm("⚠️ تأیید نهایی؟")) return;
|
||
HistoryManager.pushState();
|
||
projectData.appData = { environments: [], products: [] };
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast("🗑️ پاکسازی شد", "success");
|
||
},
|
||
showGalaxyChart() {
|
||
GalaxyChartManager.open();
|
||
},
|
||
showExecutiveDashboard() {
|
||
DashboardManager.open();
|
||
},
|
||
showDependencyAnalysis() {
|
||
DependencyAnalysisManager.open();
|
||
},
|
||
showDeploymentAnalysis() {
|
||
DeploymentAnalysisManager.open();
|
||
},
|
||
showVersionAnalysis() {
|
||
VersionAnalysisManager.open();
|
||
},
|
||
showRiskAnalysis() {
|
||
RiskAnalysisManager.open();
|
||
},
|
||
defaultAction(tool) {
|
||
showToast(`ابزار: ${tool.title}`, "info");
|
||
},
|
||
};
|
||
/* ==================== WEBHOOK MANAGER ==================== */
|
||
const WebhookManager = {
|
||
getConfig() {
|
||
return projectData.webhook;
|
||
},
|
||
setConfig(c) {
|
||
projectData.webhook = { ...projectData.webhook, ...c };
|
||
this.updateStatusUI();
|
||
},
|
||
isValid() {
|
||
const c = this.getConfig();
|
||
return (
|
||
c.enabled && c.url && c.clientId && /^https?:\/\//i.test(c.url)
|
||
);
|
||
},
|
||
updateStatusUI() {
|
||
const c = this.getConfig();
|
||
const d = document.getElementById("webhookStatusDot"),
|
||
t = document.getElementById("webhookStatusText");
|
||
if (!d || !t) return;
|
||
d.className = "webhook-status-dot";
|
||
if (!c.enabled) t.textContent = "وضعیت: غیرفعال";
|
||
else if (!c.url || !c.clientId) {
|
||
d.classList.add("pending");
|
||
t.textContent = "وضعیت: ناقص";
|
||
} else {
|
||
d.classList.add("success");
|
||
t.textContent = "وضعیت: آماده";
|
||
}
|
||
},
|
||
async sendEvent(en, payload = {}) {
|
||
if (!this.isValid()) return;
|
||
const c = this.getConfig();
|
||
try {
|
||
await fetch(c.url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Client-Id": c.clientId,
|
||
"X-Event-Name": en,
|
||
},
|
||
body: JSON.stringify({
|
||
event: en,
|
||
timestamp: new Date().toISOString(),
|
||
projectId: projectData.projectId,
|
||
data: payload,
|
||
}),
|
||
});
|
||
} catch (e) {}
|
||
},
|
||
};
|
||
/* ==================== ENVIRONMENT MANAGER ==================== */
|
||
const EnvironmentManager = {
|
||
currentEditEnvId: null,
|
||
openAddModal() {
|
||
document.getElementById("newEnvName").value = "";
|
||
document.getElementById("newEnvDesc").value = "";
|
||
document.getElementById("newEnvBgColor").value = "#38a169";
|
||
document.getElementById("newEnvTextColor").value = "#ffffff";
|
||
ModalManager.open("envManagerModal");
|
||
},
|
||
saveNewEnv() {
|
||
const n = document.getElementById("newEnvName").value.trim();
|
||
if (!n) {
|
||
showToast("نام الزامی است", "error");
|
||
return;
|
||
}
|
||
if (
|
||
projectData.appData.environments.some(
|
||
(e) => e.name.toLowerCase() === n.toLowerCase(),
|
||
)
|
||
) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
projectData.appData.environments.push({
|
||
id: `env-${Date.now()}`,
|
||
name: n,
|
||
description:
|
||
document.getElementById("newEnvDesc").value.trim() || "",
|
||
backgroundColor: document.getElementById("newEnvBgColor").value,
|
||
textColor: document.getElementById("newEnvTextColor").value,
|
||
});
|
||
ModalManager.close("envManagerModal");
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`محیط "${n}" اضافه شد`, "success");
|
||
},
|
||
openEditModal(envId) {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === envId,
|
||
);
|
||
if (!env) return;
|
||
this.currentEditEnvId = envId;
|
||
document.getElementById("editEnvId").value = env.id;
|
||
document.getElementById("editEnvName").value = env.name;
|
||
document.getElementById("editEnvDesc").value = env.description || "";
|
||
document.getElementById("editEnvBgColor").value =
|
||
env.backgroundColor || "#38a169";
|
||
document.getElementById("editEnvTextColor").value =
|
||
env.textColor || "#ffffff";
|
||
this.updateEditPreview();
|
||
ModalManager.open("envEditModal");
|
||
},
|
||
updateEditPreview() {
|
||
const p = document.getElementById("editEnvPreview");
|
||
p.style.backgroundColor =
|
||
document.getElementById("editEnvBgColor").value;
|
||
p.style.color = document.getElementById("editEnvTextColor").value;
|
||
},
|
||
saveEdit() {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === this.currentEditEnvId,
|
||
);
|
||
if (!env) return;
|
||
const nn = document.getElementById("editEnvName").value.trim();
|
||
if (!nn) {
|
||
showToast("نام الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
env.name = nn;
|
||
env.description = document.getElementById("editEnvDesc").value.trim();
|
||
env.backgroundColor = document.getElementById("editEnvBgColor").value;
|
||
env.textColor = document.getElementById("editEnvTextColor").value;
|
||
ModalManager.close("envEditModal");
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`محیط "${env.name}" ویرایش شد`, "success");
|
||
},
|
||
remove(id) {
|
||
const env = projectData.appData.environments.find((e) => e.id === id);
|
||
if (!env) return;
|
||
if (!confirm(`حذف "${env.name}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
projectData.appData.environments =
|
||
projectData.appData.environments.filter((e) => e.id !== id);
|
||
ToolbarManager.populateEnvironmentFilter();
|
||
refreshUI();
|
||
showToast(`"${env.name}" حذف شد`, "warning");
|
||
},
|
||
};
|
||
/* ==================== PRODUCT MANAGER ==================== */
|
||
const ProductManager = {
|
||
currentDetailProductId: null,
|
||
currentEditProjectId: null,
|
||
currentDependencyProjectId: null,
|
||
currentDependencyProjId: null,
|
||
currentDeploymentProjId: null,
|
||
currentDeploymentMode: null,
|
||
currentDeploymentEnvId: null,
|
||
currentEditDeploymentId: null,
|
||
getTypeInfo(type) {
|
||
return (
|
||
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
|
||
value: type,
|
||
label: type,
|
||
icon: "❓",
|
||
}
|
||
);
|
||
},
|
||
getEnvironmentName(envId) {
|
||
const e = projectData.appData.environments.find(
|
||
(x) => x.id === envId,
|
||
);
|
||
return e ? e.name : envId;
|
||
},
|
||
getEnvironmentColors(envId) {
|
||
const e = projectData.appData.environments.find(
|
||
(x) => x.id === envId,
|
||
);
|
||
return {
|
||
backgroundColor: e?.backgroundColor || "#38a169",
|
||
textColor: e?.textColor || "#ffffff",
|
||
};
|
||
},
|
||
findProjectById(pid) {
|
||
for (const prod of projectData.appData.products) {
|
||
const pr = prod.projects.find((p) => p.id === pid);
|
||
if (pr) return pr;
|
||
}
|
||
return null;
|
||
},
|
||
getActiveDeployments(proj) {
|
||
return (proj.deployments || []).filter((d) => d.status === "active");
|
||
},
|
||
getEnvironmentsWithoutDeployment(proj) {
|
||
const allEnvIds = projectData.appData.environments.map((e) => e.id);
|
||
const deployedEnvIds = this.getActiveDeployments(proj).map(
|
||
(d) => d.environmentId,
|
||
);
|
||
return allEnvIds.filter((id) => !deployedEnvIds.includes(id));
|
||
},
|
||
showDetail(productId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
this.currentDetailProductId = productId;
|
||
document.getElementById("projectDetailTitle").innerHTML =
|
||
`<span>📦</span><span>${prod.title}</span>`;
|
||
const projects = prod.projects || [];
|
||
const totalDeploys = projects.reduce(
|
||
(s, pr) => s + this.getActiveDeployments(pr).length,
|
||
0,
|
||
);
|
||
let html = `<div class="detail-section"><div class="detail-section-header"><span>ℹ️ اطلاعات محصول</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openProductEditModal()">✏️ ویرایش</button></div></div><div class="detail-section-body"><div class="detail-info-grid"><div class="detail-info-item"><span class="detail-info-label">عنوان</span><span class="detail-info-value">${prod.title}</span></div><div class="detail-info-item"><span class="detail-info-label">توضیحات</span><span class="detail-info-value">${prod.description || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تاریخ ایجاد</span><span class="detail-info-value">${prod.metadata?.createdAt ? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR") : "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">مالک</span><span class="detail-info-value">${prod.metadata?.owner || "—"}</span></div><div class="detail-info-item"><span class="detail-info-label">تعداد پروژه</span><span class="detail-info-value">${projects.length}</span></div><div class="detail-info-item"><span class="detail-info-label">استقرارهای فعال</span><span class="detail-info-value">${totalDeploys}</span></div></div></div></div><div class="detail-section"><div class="detail-section-header"><span>🌿 پروژهها (${projects.length})</span><div class="detail-section-header-actions"><button class="btn btn-accent btn-sm" onclick="ProductManager.openAddProjectModal()">➕ افزودن</button></div></div><div class="detail-section-body">`;
|
||
if (projects.length === 0) {
|
||
html += '<div class="empty-subprojects">🌿 هیچ پروژهای نیست</div>';
|
||
} else {
|
||
html += '<div class="subproject-list">';
|
||
projects.forEach((pr) => {
|
||
const ti = this.getTypeInfo(pr.type);
|
||
const deps = pr.dependencies || [];
|
||
const deploys = this.getActiveDeployments(pr);
|
||
const emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
|
||
const canAdd = emptyEnvs.length > 0;
|
||
html += `<div class="subproject-card"><div class="subproject-header"><div class="subproject-title"><span>${ti.icon}</span><span>${pr.title}</span><span class="type-badge type-${pr.type}">${ti.label}</span></div><div class="subproject-actions"><button class="btn btn-ghost btn-sm" onclick="ProductManager.openDependencyManager('${prod.id}','${pr.id}')">🔗 وابستگیها</button><button class="btn btn-ghost btn-sm" onclick="ProductManager.openProjectEditModal('${prod.id}','${pr.id}')">✏️</button><button class="btn btn-danger btn-sm" onclick="ProductManager.removeProject('${prod.id}','${pr.id}')">🗑️</button></div></div><div class="subproject-meta">${pr.description || ""}</div><div class="detail-info-grid" style="margin-bottom:8px">${pr.metadata?.framework ? `<div class="detail-info-item"><span class="detail-info-label">فریمورک</span><span class="detail-info-value">${pr.metadata.framework}</span></div>` : ""}${pr.metadata?.language ? `<div class="detail-info-item"><span class="detail-info-label">زبان</span><span class="detail-info-value">${pr.metadata.language}</span></div>` : ""}${
|
||
deps.length > 0
|
||
? `<div class="detail-info-item"><span class="detail-info-label">وابستگیها (${deps.length})</span><span class="detail-info-value">${deps
|
||
.map((depId) => {
|
||
const dp = this.findProjectById(depId);
|
||
return dp ? dp.title : depId;
|
||
})
|
||
.join("، ")}</span></div>`
|
||
: ""
|
||
}</div>`;
|
||
html += `<div class="deployment-section"><div class="deployment-header"><span class="deployment-header-title">🚀 استقرارهای فعال (${deploys.length})</span>${canAdd ? `<button class="btn btn-accent btn-xs" onclick="ProductManager.openAddDeploymentModal('${prod.id}','${pr.id}')">➕ افزودن</button>` : '<span style="font-size:10px;color:var(--text-muted)">✓ کامل</span>'}</div>`;
|
||
if (deploys.length === 0) {
|
||
html +=
|
||
'<div class="empty-deployments">هنوز استقرار ندارد</div>';
|
||
} else {
|
||
html += '<div class="deployment-list">';
|
||
deploys.forEach((d) => {
|
||
const en = this.getEnvironmentName(d.environmentId);
|
||
const cl = this.getEnvironmentColors(d.environmentId);
|
||
const fd = d.deploymentDate
|
||
? new Date(d.deploymentDate).toLocaleString("fa-IR")
|
||
: "—";
|
||
html += `<div class="deployment-item"><div class="deployment-item-info"><span class="deployment-env-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${en}</span><span class="deployment-version">v${d.version}</span><span class="deployment-date">📅 ${fd}</span></div><div class="deployment-actions"><button class="btn btn-ghost btn-xs" onclick="ProductManager.openEditDeploymentModal('${prod.id}','${pr.id}','${d.id}')">✏️</button><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDeployment('${prod.id}','${pr.id}','${d.id}')">🗑️</button></div>`;
|
||
if (d.releaseNotes) {
|
||
html += `<div class="deployment-notes">📝 ${d.releaseNotes}</div>`;
|
||
}
|
||
if (
|
||
d.requiredDeployments &&
|
||
d.requiredDeployments.length > 0
|
||
) {
|
||
html += `<div class="deployment-req">🔗 نیازمندیها: ${d.requiredDeployments
|
||
.map((rd) => {
|
||
const rp = this.findProjectById(rd.projectId);
|
||
let depVer = "";
|
||
if (rp) {
|
||
const rdDep = (rp.deployments || []).find(
|
||
(x) => x.id === rd.deploymentId,
|
||
);
|
||
if (rdDep) depVer = ` (v${rdDep.version})`;
|
||
}
|
||
return `${rp ? rp.title : rd.projectId}${depVer}`;
|
||
})
|
||
.join("، ")}</div>`;
|
||
}
|
||
html += `</div>`;
|
||
});
|
||
html += "</div>";
|
||
}
|
||
html += `</div></div>`;
|
||
});
|
||
html += "</div>";
|
||
}
|
||
html += `</div></div>`;
|
||
document.getElementById("projectDetailBody").innerHTML = html;
|
||
ModalManager.open("projectDetailModal");
|
||
},
|
||
openAddProductModal() {
|
||
document.getElementById("newProductTitle").value = "";
|
||
document.getElementById("newProductDescription").value = "";
|
||
document.getElementById("newProductOwner").value = "";
|
||
document.getElementById("newProductCreatedAt").value =
|
||
new Date().toLocaleDateString("fa-IR");
|
||
ModalManager.open("addProductModal");
|
||
},
|
||
createNewProduct() {
|
||
const title = document.getElementById("newProductTitle").value.trim();
|
||
if (!title || title.length < 3) {
|
||
showToast("عنوان الزامی است (حداقل ۳ کاراکتر)", "error");
|
||
return;
|
||
}
|
||
if (
|
||
projectData.appData.products.some(
|
||
(p) => p.title.toLowerCase() === title.toLowerCase(),
|
||
)
|
||
) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
projectData.appData.products.push({
|
||
id: `prod-${Date.now()}`,
|
||
title,
|
||
description:
|
||
document.getElementById("newProductDescription").value.trim() ||
|
||
"",
|
||
metadata: {
|
||
createdAt: new Date().toISOString(),
|
||
owner:
|
||
document.getElementById("newProductOwner").value.trim() || "",
|
||
customFields: {},
|
||
},
|
||
projects: [],
|
||
});
|
||
ModalManager.close("addProductModal");
|
||
refreshUI();
|
||
showToast(`"${title}" ایجاد شد`, "success");
|
||
},
|
||
openDependencyManager(productId, projId) {
|
||
this.currentDependencyProjectId = productId;
|
||
this.currentDependencyProjId = projId;
|
||
this.renderCurrentDependencies();
|
||
this.renderAllProjectsList();
|
||
this.setupDependencySearch();
|
||
ModalManager.open("dependencyManagerModal");
|
||
},
|
||
renderCurrentDependencies() {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
const c = document.getElementById("currentDependenciesList");
|
||
const deps = pr.dependencies || [];
|
||
if (deps.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="empty-dependencies">هیچ وابستگیای نیست</div>';
|
||
return;
|
||
}
|
||
c.innerHTML = deps
|
||
.map((depId, index) => {
|
||
const depPr = this.findProjectById(depId);
|
||
const projectName = depPr ? depPr.title : depId;
|
||
const typeInfo = depPr ? this.getTypeInfo(depPr.type) : null;
|
||
return `<div class="dependency-item"><div class="dependency-info"><div><div class="dependency-project-name">${typeInfo ? typeInfo.icon + " " : ""}${projectName}</div></div></div><button class="btn btn-danger btn-xs" onclick="ProductManager.removeDependency(${index})">🗑️</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
removeDependency(index) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
if (!confirm("حذف این وابستگی؟")) return;
|
||
HistoryManager.pushState();
|
||
pr.dependencies.splice(index, 1);
|
||
ModalManager.close("dependencyManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("وابستگی حذف شد", "success");
|
||
},
|
||
renderAllProjectsList() {
|
||
const rc = document.getElementById("dependencySearchResults");
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
if (allProjects.length === 0) {
|
||
rc.innerHTML =
|
||
'<div class="empty-dependencies">پروژه دیگری نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = allProjects
|
||
.map((pr) => {
|
||
const isDep = (cpr.dependencies || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted);font-weight:normal">(${pr.productName})</small>${isDep ? '<span style="margin-right:auto;font-size:10px;color:var(--success)">✓ وابسته</span>' : ""}</button></div>`;
|
||
})
|
||
.join("");
|
||
},
|
||
setupDependencySearch() {
|
||
const si = document.getElementById("dependencySearch");
|
||
si.value = "";
|
||
si.oninput = (e) => {
|
||
const q = e.target.value.trim().toLowerCase();
|
||
if (q.length === 0) {
|
||
this.renderAllProjectsList();
|
||
return;
|
||
}
|
||
const cp = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!cp) return;
|
||
const cpr = cp.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!cpr) return;
|
||
const allProjects = [];
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
if (pr.id !== cpr.id)
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
});
|
||
});
|
||
const fl = allProjects.filter(
|
||
(pr) =>
|
||
pr.title.toLowerCase().includes(q) ||
|
||
pr.productName.toLowerCase().includes(q) ||
|
||
(pr.description || "").toLowerCase().includes(q),
|
||
);
|
||
const rc = document.getElementById("dependencySearchResults");
|
||
if (fl.length === 0) {
|
||
rc.innerHTML =
|
||
'<div class="empty-dependencies">نتیجهای نیست</div>';
|
||
return;
|
||
}
|
||
rc.innerHTML = fl
|
||
.map((pr) => {
|
||
const isDep = (cpr.dependencies || []).includes(pr.id);
|
||
return `<div class="dependency-project-result"><button class="dependency-project-add-btn" onclick="ProductManager.addDependency('${pr.id}')" ${isDep ? 'disabled style="opacity:.5"' : ""}><span>${this.getTypeInfo(pr.type).icon}</span><span>${pr.title}</span><small style="color:var(--text-muted)">(${pr.productName})</small></button></div>`;
|
||
})
|
||
.join("");
|
||
};
|
||
},
|
||
addDependency(projectId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDependencyProjectId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDependencyProjId,
|
||
);
|
||
if (!pr) return;
|
||
if ((pr.dependencies || []).includes(projectId)) {
|
||
showToast("تکراری است", "warning");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
if (!pr.dependencies) pr.dependencies = [];
|
||
pr.dependencies.push(projectId);
|
||
ModalManager.close("dependencyManagerModal");
|
||
refreshUI();
|
||
if (this.currentDetailProductId)
|
||
this.showDetail(this.currentDetailProductId);
|
||
showToast("وابستگی اضافه شد", "success");
|
||
},
|
||
openProductEditModal() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
document.getElementById("editProductId").value = prod.id;
|
||
document.getElementById("editProductTitle").value = prod.title;
|
||
document.getElementById("editProductDescription").value =
|
||
prod.description || "";
|
||
document.getElementById("editProductOwner").value =
|
||
prod.metadata?.owner || "";
|
||
document.getElementById("editProductCreatedAt").value = prod.metadata
|
||
?.createdAt
|
||
? new Date(prod.metadata.createdAt).toLocaleDateString("fa-IR")
|
||
: "—";
|
||
ModalManager.open("productEditModal");
|
||
},
|
||
saveProductEdit() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const nt = document.getElementById("editProductTitle").value.trim();
|
||
if (!nt) {
|
||
showToast("عنوان الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
prod.title = nt;
|
||
prod.description = document
|
||
.getElementById("editProductDescription")
|
||
.value.trim();
|
||
if (!prod.metadata) prod.metadata = {};
|
||
prod.metadata.owner = document
|
||
.getElementById("editProductOwner")
|
||
.value.trim();
|
||
prod.metadata.updatedAt = new Date().toISOString();
|
||
ModalManager.close("productEditModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
showToast(`"${prod.title}" ویرایش شد`, "success");
|
||
},
|
||
openAddProjectModal() {
|
||
if (!this.currentDetailProductId) {
|
||
showToast("ابتدا محصول انتخاب کنید", "warning");
|
||
return;
|
||
}
|
||
document.getElementById("newProjectTitle").value = "";
|
||
document.getElementById("newProjectType").value = "";
|
||
document.getElementById("newProjectFramework").value = "";
|
||
document.getElementById("newProjectLanguage").value = "";
|
||
document.getElementById("newProjectRepo").value = "";
|
||
document.getElementById("newProjectDescription").value = "";
|
||
populateProjectTypeDropdowns();
|
||
ModalManager.open("addProjectModal");
|
||
},
|
||
saveNewProject() {
|
||
const pid = this.currentDetailProductId;
|
||
if (!pid) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const title = document.getElementById("newProjectTitle").value.trim();
|
||
const type = document.getElementById("newProjectType").value;
|
||
if (!title || !type) {
|
||
showToast("عنوان و نوع الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
const np = {
|
||
id: `proj-${Date.now()}`,
|
||
title,
|
||
description:
|
||
document.getElementById("newProjectDescription").value.trim() ||
|
||
"",
|
||
type,
|
||
repositoryUrl: document
|
||
.getElementById("newProjectRepo")
|
||
.value.trim(),
|
||
metadata: { createdAt: new Date().toISOString(), customFields: {} },
|
||
dependencies: [],
|
||
deployments: [],
|
||
};
|
||
const fw = document
|
||
.getElementById("newProjectFramework")
|
||
.value.trim();
|
||
if (fw) np.metadata.framework = fw;
|
||
const lg = document.getElementById("newProjectLanguage").value.trim();
|
||
if (lg) np.metadata.language = lg;
|
||
prod.projects.push(np);
|
||
ModalManager.close("addProjectModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
showToast(`"${title}" ایجاد شد`, "success");
|
||
},
|
||
openProjectEditModal(productId, projId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
this.currentEditProjectId = projId;
|
||
populateProjectTypeDropdowns();
|
||
document.getElementById("editProjId").value = pr.id;
|
||
document.getElementById("editProjTitle").value = pr.title;
|
||
document.getElementById("editProjType").value = pr.type;
|
||
document.getElementById("editProjFramework").value =
|
||
pr.metadata?.framework || "";
|
||
document.getElementById("editProjLanguage").value =
|
||
pr.metadata?.language || "";
|
||
document.getElementById("editProjRepo").value =
|
||
pr.repositoryUrl || "";
|
||
document.getElementById("editProjDescription").value =
|
||
pr.description || "";
|
||
ModalManager.open("editProjectModal");
|
||
},
|
||
saveProjectEdit() {
|
||
const pid = this.currentDetailProductId;
|
||
const projId = this.currentEditProjectId;
|
||
if (!pid || !projId) return;
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const nt = document.getElementById("editProjTitle").value.trim();
|
||
const nty = document.getElementById("editProjType").value;
|
||
if (!nt || !nty) {
|
||
showToast("عنوان و نوع الزامی است", "error");
|
||
return;
|
||
}
|
||
HistoryManager.pushState();
|
||
pr.title = nt;
|
||
pr.type = nty;
|
||
pr.description =
|
||
document.getElementById("editProjDescription").value.trim() || "";
|
||
pr.repositoryUrl = document
|
||
.getElementById("editProjRepo")
|
||
.value.trim();
|
||
if (!pr.metadata) pr.metadata = {};
|
||
const fw = document.getElementById("editProjFramework").value.trim();
|
||
if (fw) pr.metadata.framework = fw;
|
||
else delete pr.metadata.framework;
|
||
const lg = document.getElementById("editProjLanguage").value.trim();
|
||
if (lg) pr.metadata.language = lg;
|
||
else delete pr.metadata.language;
|
||
pr.metadata.updatedAt = new Date().toISOString();
|
||
ModalManager.close("editProjectModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
showToast(`"${pr.title}" ویرایش شد`, "success");
|
||
},
|
||
openAddDeploymentModal(productId, projId) {
|
||
if (!productId || !projId) return;
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const emptyEnvs = this.getEnvironmentsWithoutDeployment(pr);
|
||
if (emptyEnvs.length === 0) {
|
||
showToast("همه محیطها استقرار دارند", "warning");
|
||
return;
|
||
}
|
||
this.currentDeploymentProjId = projId;
|
||
this.currentDeploymentMode = "add";
|
||
this.currentDeploymentEnvId = null;
|
||
this.currentEditDeploymentId = null;
|
||
document.getElementById("deploymentModalTitle").innerHTML =
|
||
"<span>🚀</span><span>افزودن نسخه استقرار</span>";
|
||
const envGroup = document.getElementById("deploymentEnvGroup");
|
||
const envs = projectData.appData.environments.filter((e) =>
|
||
emptyEnvs.includes(e.id),
|
||
);
|
||
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="required">*</span></label><select class="form-select" id="depEnvironment" onchange="ProductManager.onEnvChange()">${envs
|
||
.map((e) => {
|
||
const cl = this.getEnvironmentColors(e.id);
|
||
return `<option value="${e.id}" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${e.name} - ${e.description}</option>`;
|
||
})
|
||
.join("")}</select>`;
|
||
document.getElementById("depVersion").value = "";
|
||
document.getElementById("depDate").value = "";
|
||
document.getElementById("depReleaseNotes").value = "";
|
||
this.renderRequiredDeploymentsSection();
|
||
ModalManager.open("deploymentModal");
|
||
},
|
||
openEditDeploymentModal(productId, projId, depId) {
|
||
if (!productId || !projId || !depId) return;
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const dep = (pr.deployments || []).find((d) => d.id === depId);
|
||
if (!dep) return;
|
||
this.currentDeploymentProjId = projId;
|
||
this.currentDeploymentMode = "edit";
|
||
this.currentDeploymentEnvId = dep.environmentId;
|
||
this.currentEditDeploymentId = depId;
|
||
document.getElementById("deploymentModalTitle").innerHTML =
|
||
"<span>✏️</span><span>ویرایش نسخه استقرار</span>";
|
||
const envGroup = document.getElementById("deploymentEnvGroup");
|
||
const cl = this.getEnvironmentColors(dep.environmentId);
|
||
envGroup.innerHTML = `<label class="form-label">محیط استقرار <span class="readonly-badge">فقط خواندنی</span></label><div class="env-read-only-tag" style="background-color:${cl.backgroundColor};color:${cl.textColor}">${this.getEnvironmentName(dep.environmentId)}</div>`;
|
||
document.getElementById("depVersion").value = dep.version;
|
||
let dv = "";
|
||
if (dep.deploymentDate) {
|
||
const d = new Date(dep.deploymentDate);
|
||
const pad = (n) => n.toString().padStart(2, "0");
|
||
dv = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
document.getElementById("depDate").value = dv;
|
||
document.getElementById("depReleaseNotes").value =
|
||
dep.releaseNotes || "";
|
||
this.renderRequiredDeploymentsSection(dep);
|
||
ModalManager.open("deploymentModal");
|
||
},
|
||
onEnvChange() {
|
||
this.renderRequiredDeploymentsSection();
|
||
},
|
||
renderRequiredDeploymentsSection(existingDep) {
|
||
const section = document.getElementById("requiredDeploymentsSection");
|
||
const envSelect = document.getElementById("depEnvironment");
|
||
const envId =
|
||
this.currentDeploymentMode === "edit"
|
||
? this.currentDeploymentEnvId
|
||
: envSelect
|
||
? envSelect.value
|
||
: null;
|
||
if (!envId) {
|
||
section.innerHTML = "";
|
||
return;
|
||
}
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === this.currentDetailProductId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find(
|
||
(p) => p.id === this.currentDeploymentProjId,
|
||
);
|
||
if (!pr) return;
|
||
const deps = pr.dependencies || [];
|
||
if (deps.length === 0) {
|
||
section.innerHTML = "";
|
||
return;
|
||
}
|
||
let h =
|
||
'<div class="form-group"><label class="form-label">🔗 استقرارهای مورد نیاز (به ازای هر وابستگی) <span class="required">*</span></label><div class="form-hint">برای هر وابستگی، یک استقرار از آن پروژه در محیط "' +
|
||
this.getEnvironmentName(envId) +
|
||
'" انتخاب کنید</div>';
|
||
deps.forEach((depId) => {
|
||
const depPr = this.findProjectById(depId);
|
||
if (!depPr) return;
|
||
const depDeployments = (depPr.deployments || []).filter(
|
||
(d) => d.status === "active" && d.environmentId === envId,
|
||
);
|
||
const existingReq = existingDep
|
||
? (existingDep.requiredDeployments || []).find(
|
||
(r) => r.projectId === depId,
|
||
)
|
||
: null;
|
||
h += `<div class="req-deployment-box"><div class="req-deployment-title">📦 ${depPr.title}</div>`;
|
||
if (depDeployments.length === 0) {
|
||
h += `<div style="color:var(--danger);font-size:11px">⚠️ هیچ استقرار فعالی در ${this.getEnvironmentName(envId)} ندارد.</div>`;
|
||
} else {
|
||
h += `<select class="req-deployment-select" data-dep-project="${depId}">${depDeployments.map((d) => `<option value="${d.id}" ${existingReq && existingReq.deploymentId === d.id ? "selected" : ""}>v${d.version} - ${d.deploymentDate ? new Date(d.deploymentDate).toLocaleDateString("fa-IR") : ""}</option>`).join("")}</select>`;
|
||
}
|
||
h += "</div>";
|
||
});
|
||
h += "</div>";
|
||
section.innerHTML = h;
|
||
},
|
||
saveDeployment() {
|
||
const pid = this.currentDetailProductId;
|
||
const projId = this.currentDeploymentProjId;
|
||
if (!pid || !projId) {
|
||
showToast("خطا: محصول یا پروژه مشخص نشده", "error");
|
||
return;
|
||
}
|
||
const prod = projectData.appData.products.find((p) => p.id === pid);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const version = document.getElementById("depVersion").value.trim();
|
||
const date = document.getElementById("depDate").value;
|
||
const releaseNotes = document
|
||
.getElementById("depReleaseNotes")
|
||
.value.trim();
|
||
if (!version) {
|
||
showToast("نسخه الزامی است", "error");
|
||
return;
|
||
}
|
||
if (!date) {
|
||
showToast("تاریخ استقرار الزامی است", "error");
|
||
return;
|
||
}
|
||
if (!releaseNotes) {
|
||
showToast("ریلیز نوت الزامی است", "error");
|
||
return;
|
||
}
|
||
const deploymentDate = new Date(date).toISOString();
|
||
HistoryManager.pushState();
|
||
if (!pr.deployments) pr.deployments = [];
|
||
const requiredDeployments = [];
|
||
document.querySelectorAll(".req-deployment-select").forEach((sel) => {
|
||
const depProjectId = sel.dataset.depProject;
|
||
const depDeploymentId = sel.value;
|
||
if (depProjectId && depDeploymentId) {
|
||
requiredDeployments.push({
|
||
projectId: depProjectId,
|
||
deploymentId: depDeploymentId,
|
||
});
|
||
}
|
||
});
|
||
if (this.currentDeploymentMode === "add") {
|
||
const envId = document.getElementById("depEnvironment").value;
|
||
if (!envId) {
|
||
showToast("محیط را انتخاب کنید", "error");
|
||
return;
|
||
}
|
||
pr.deployments.push({
|
||
id: `dep-${Date.now()}`,
|
||
environmentId: envId,
|
||
version,
|
||
deploymentDate,
|
||
releaseNotes,
|
||
requiredDeployments,
|
||
status: "active",
|
||
});
|
||
showToast(
|
||
`v${version} در ${this.getEnvironmentName(envId)} استقرار یافت`,
|
||
"success",
|
||
);
|
||
} else {
|
||
const depId = this.currentEditDeploymentId;
|
||
const dep = pr.deployments.find((d) => d.id === depId);
|
||
if (dep) {
|
||
dep.version = version;
|
||
dep.deploymentDate = deploymentDate;
|
||
dep.releaseNotes = releaseNotes;
|
||
dep.requiredDeployments = requiredDeployments;
|
||
showToast(`استقرار ویرایش شد`, "success");
|
||
}
|
||
}
|
||
ModalManager.close("deploymentModal");
|
||
refreshUI();
|
||
this.showDetail(pid);
|
||
},
|
||
removeDeployment(productId, projId, depId) {
|
||
if (!confirm("حذف این استقرار؟")) return;
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
const dep = (pr.deployments || []).find((d) => d.id === depId);
|
||
if (!dep) return;
|
||
HistoryManager.pushState();
|
||
dep.status = "removed";
|
||
refreshUI();
|
||
this.showDetail(productId);
|
||
showToast("استقرار حذف شد", "warning");
|
||
},
|
||
removeProduct(productId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
if (!confirm(`حذف "${prod.title}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
projectData.appData.products = projectData.appData.products.filter(
|
||
(p) => p.id !== productId,
|
||
);
|
||
refreshUI();
|
||
ModalManager.close("projectDetailModal");
|
||
showToast(`"${prod.title}" حذف شد`, "warning");
|
||
},
|
||
removeProject(productId, projId) {
|
||
const prod = projectData.appData.products.find(
|
||
(p) => p.id === productId,
|
||
);
|
||
if (!prod) return;
|
||
const pr = prod.projects.find((p) => p.id === projId);
|
||
if (!pr) return;
|
||
if (!confirm(`حذف "${pr.title}"؟`)) return;
|
||
HistoryManager.pushState();
|
||
prod.projects = prod.projects.filter((p) => p.id !== projId);
|
||
refreshUI();
|
||
if (this.currentDetailProductId === productId)
|
||
this.showDetail(productId);
|
||
showToast(`"${pr.title}" حذف شد`, "warning");
|
||
},
|
||
};
|
||
/* ==================== ✅ DASHBOARD MANAGER (COMPLETED WITH D3 CHARTS) ==================== */
|
||
const DashboardManager = {
|
||
open() {
|
||
ModalManager.open("executiveDashboardModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
getReportData() {
|
||
const ps = projectData.appData.products || [];
|
||
let tp = 0,
|
||
td = 0,
|
||
tdp = 0;
|
||
const tc = {},
|
||
edc = {},
|
||
ppc = [],
|
||
fc = {};
|
||
let pwd = 0,
|
||
pwod = 0;
|
||
ps.forEach((prod) => {
|
||
const projects = prod.projects || [];
|
||
ppc.push({ title: prod.title, count: projects.length });
|
||
projects.forEach((pr) => {
|
||
tp++;
|
||
td += (pr.dependencies || []).length;
|
||
const active = (pr.deployments || []).filter(
|
||
(d) => d.status === "active",
|
||
);
|
||
tdp += active.length;
|
||
if (active.length > 0) pwd++;
|
||
else pwod++;
|
||
tc[pr.type] = (tc[pr.type] || 0) + 1;
|
||
const fw = pr.metadata?.framework || "نامشخص";
|
||
fc[fw] = (fc[fw] || 0) + 1;
|
||
active.forEach((d) => {
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
return {
|
||
products: ps.length,
|
||
projects: tp,
|
||
deps: td,
|
||
deploys: tdp,
|
||
environments: projectData.appData.environments.length,
|
||
tc,
|
||
edc,
|
||
ppc,
|
||
fc,
|
||
pwd,
|
||
pwod,
|
||
};
|
||
},
|
||
render() {
|
||
const c = document.getElementById("dashboardContainer");
|
||
if (!c) return;
|
||
const d = this.getReportData();
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.products}</div><div class="dashboard-kpi-label">محصول</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌿</div><div class="dashboard-kpi-value">${d.projects}</div><div class="dashboard-kpi-label">پروژه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.deps}</div><div class="dashboard-kpi-label">وابستگی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.deploys}</div><div class="dashboard-kpi-label">استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🌍</div><div class="dashboard-kpi-value">${d.environments}</div><div class="dashboard-kpi-label">محیط</div></div>`;
|
||
h += '</div><div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژهها</div><div class="dashboard-chart-body" id="dashTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="dashEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="dashStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 پروژهها به تفکیک محصول</div><div class="dashboard-chart-body" id="dashProductChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🛠️ توزیع فریمورکها</div><div class="dashboard-chart-body" id="dashFrameworkChart"></div></div>';
|
||
h += "</div>";
|
||
c.innerHTML = h;
|
||
this.renderDonut(
|
||
"dashTypeChart",
|
||
Object.entries(d.tc).map(([k, v]) => ({
|
||
label: this.getTypeLabel(k),
|
||
value: v,
|
||
color: this.getTypeColor(k),
|
||
})),
|
||
);
|
||
this.renderBar(
|
||
"dashEnvChart",
|
||
Object.entries(d.edc).map(([k, v]) => ({ label: k, value: v })),
|
||
"#38a169",
|
||
);
|
||
this.renderDonut("dashStatusChart", [
|
||
{ label: "با استقرار", value: d.pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: d.pwod, color: "#e53e3e" },
|
||
]);
|
||
this.renderBar(
|
||
"dashProductChart",
|
||
d.ppc.map((p) => ({
|
||
label:
|
||
p.title.length > 18
|
||
? p.title.substring(0, 18) + "..."
|
||
: p.title,
|
||
value: p.count,
|
||
})),
|
||
"#3182ce",
|
||
);
|
||
this.renderBarH(
|
||
"dashFrameworkChart",
|
||
Object.entries(d.fc)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8)
|
||
.map(([k, v]) => ({
|
||
label: k.length > 14 ? k.substring(0, 14) + "..." : k,
|
||
value: v,
|
||
})),
|
||
);
|
||
const coverage =
|
||
d.pwd + d.pwod > 0
|
||
? Math.round((d.pwd / (d.pwd + d.pwod)) * 100)
|
||
: 0;
|
||
document.getElementById("dashboardFooterStats").innerHTML =
|
||
`<span>📦 محصولات: <span class="stat-highlight">${d.products}</span></span><span>🌿 پروژهها: <span class="stat-highlight">${d.projects}</span></span><span>🚀 استقرارها: <span class="stat-highlight">${d.deploys}</span></span><span>📈 پوشش استقرار: <span class="stat-highlight">${coverage}%</span></span>`;
|
||
document.getElementById("dashboardRefresh").onclick = () =>
|
||
this.render();
|
||
},
|
||
renderDonut(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0 || data.every((d) => d.value === 0)) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((dd) => dd.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = data.reduce((s, dd) => s + dd.value, 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (dd) => dd.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(dd));
|
||
showD3Tooltip(
|
||
e,
|
||
`${dd.data.label}: ${dd.data.value} (${Math.round((dd.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("پروژه");
|
||
},
|
||
renderBar(cid, data, color) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 45, left: 35 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(data.map((dd) => dd.label))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (dd) => dd.value) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-25)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll(".grid-line")
|
||
.data(y.ticks(4))
|
||
.join("line")
|
||
.attr("x1", 0)
|
||
.attr("x2", iw)
|
||
.attr("y1", (dd) => y(dd))
|
||
.attr("y2", (dd) => y(dd))
|
||
.attr("stroke", "var(--border-light)")
|
||
.attr("stroke-dasharray", "3,3");
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", (dd) => x(dd.label))
|
||
.attr("y", (dd) => y(dd.value))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (dd) => ih - y(dd.value))
|
||
.attr("fill", color)
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", color);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (dd) => x(dd.label) + x.bandwidth() / 2)
|
||
.attr("y", (dd) => y(dd.value) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((dd) => dd.value);
|
||
},
|
||
renderBarH(cid, data) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!data || data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🛠️</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const bh = 28,
|
||
gap = 6;
|
||
const w = 320,
|
||
hgt = data.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 110 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (dd) => dd.value) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(data.map((dd) => dd.label))
|
||
.range([0, ih])
|
||
.padding(0.2);
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (dd) => y(dd.label))
|
||
.attr("width", (dd) => x(dd.value))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", "#2c5282")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, dd) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${dd.label}: ${dd.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#2c5282");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((dd) => dd.label);
|
||
g.selectAll(".value")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (dd) => x(dd.value) + 5)
|
||
.attr("y", (dd) => y(dd.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((dd) => dd.value);
|
||
},
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
getTypeColor(t) {
|
||
const c = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
return c[t] || "#718096";
|
||
},
|
||
};
|
||
/* ==================== GALAXY CHART ==================== */
|
||
const GalaxyChartManager = {
|
||
svg: null,
|
||
zoomBehavior: null,
|
||
zoomGroup: null,
|
||
nodeElements: null,
|
||
linkElements: null,
|
||
labelElements: null,
|
||
allNodes: [],
|
||
collapsedProducts: new Set(), // ← NEW: وضعیت کولاپس محصولات
|
||
NODE_COLORS: {
|
||
product: "#c9a961",
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
},
|
||
|
||
open() {
|
||
ModalManager.open("galaxyChartModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
|
||
/* ── NEW: ساخت گراف با منطق کولاپس (راهبرد الف) ── */
|
||
buildGraphData() {
|
||
const nodes = [];
|
||
const links = [];
|
||
const projectToProduct = new Map();
|
||
|
||
// ساخت نگاشت پروژه -> محصول
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
projectToProduct.set(pr.id, prod.id);
|
||
});
|
||
});
|
||
|
||
// ساخت نودها
|
||
projectData.appData.products.forEach((prod) => {
|
||
const isCollapsed = this.collapsedProducts.has(prod.id);
|
||
nodes.push({
|
||
id: prod.id,
|
||
label: prod.title,
|
||
type: "product",
|
||
radius: 30,
|
||
data: prod,
|
||
collapsed: isCollapsed,
|
||
collapsedCount: isCollapsed ? (prod.projects || []).length : 0,
|
||
});
|
||
|
||
// اگر محصول کولاپس نیست، پروژههایش را اضافه کن
|
||
if (!isCollapsed) {
|
||
(prod.projects || []).forEach((pr) => {
|
||
nodes.push({
|
||
id: pr.id,
|
||
label: pr.title,
|
||
type: "project",
|
||
projectType: pr.type,
|
||
radius: 16,
|
||
data: pr,
|
||
productTitle: prod.title,
|
||
productId: prod.id,
|
||
});
|
||
links.push({
|
||
source: prod.id,
|
||
target: pr.id,
|
||
type: "ownership",
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// ساخت یالهای وابستگی با هدایت به نود محصول کولاپسشده (راهبرد الف)
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
let sourceId = pr.id;
|
||
let targetId = depId;
|
||
|
||
// اگر پروژه مبدأ کولاپس است، وابستگی را به نود محصول هدایت کن
|
||
const sourceProductId = projectToProduct.get(pr.id);
|
||
if (
|
||
sourceProductId &&
|
||
this.collapsedProducts.has(sourceProductId)
|
||
) {
|
||
sourceId = sourceProductId;
|
||
}
|
||
|
||
// اگر پروژه مقصد کولاپس است، وابستگی را به نود محصول هدایت کن
|
||
const targetProductId = projectToProduct.get(depId);
|
||
if (
|
||
targetProductId &&
|
||
this.collapsedProducts.has(targetProductId)
|
||
) {
|
||
targetId = targetProductId;
|
||
}
|
||
|
||
// جلوگیری از یال به خود و یالهای تکراری
|
||
if (sourceId === targetId) return;
|
||
const exists = links.some((l) => {
|
||
const s =
|
||
typeof l.source === "object" ? l.source.id : l.source;
|
||
const t =
|
||
typeof l.target === "object" ? l.target.id : l.target;
|
||
return (
|
||
s === sourceId && t === targetId && l.type === "dependency"
|
||
);
|
||
});
|
||
if (!exists) {
|
||
links.push({
|
||
source: sourceId,
|
||
target: targetId,
|
||
type: "dependency",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return { nodes, links };
|
||
},
|
||
|
||
/* ── NEW: کولاپس/بازکردن پروژههای یک محصول ── */
|
||
toggleCollapseProduct(productId) {
|
||
if (this.collapsedProducts.has(productId)) {
|
||
this.collapsedProducts.delete(productId);
|
||
} else {
|
||
this.collapsedProducts.add(productId);
|
||
}
|
||
this.render(); // بازسازی کامل گراف
|
||
},
|
||
|
||
/* ── کولاپس همه محصولات ── */
|
||
collapseAllProducts() {
|
||
projectData.appData.products.forEach((prod) => {
|
||
this.collapsedProducts.add(prod.id);
|
||
});
|
||
this.render();
|
||
showToast(
|
||
`⊟ ${projectData.appData.products.length} محصول کولاپس شدند`,
|
||
"info",
|
||
2000,
|
||
);
|
||
},
|
||
|
||
/* ── باز کردن همه محصولات ── */
|
||
expandAllProducts() {
|
||
const count = this.collapsedProducts.size;
|
||
this.collapsedProducts.clear();
|
||
this.render();
|
||
showToast(`⊞ ${count} محصول از حالت کولاپس خارج شدند`, "info", 2000);
|
||
},
|
||
|
||
/* ── رندر اصلی ── */
|
||
render() {
|
||
const c = document.getElementById("galaxyChartContainer");
|
||
c.innerHTML = "";
|
||
const w = c.clientWidth || 900,
|
||
h = c.clientHeight || 600;
|
||
|
||
// استفاده از متد buildGraphData
|
||
const { nodes, links } = this.buildGraphData();
|
||
this.allNodes = nodes;
|
||
|
||
if (nodes.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="galaxy-empty"><div style="font-size:60px">🌌</div><div>هیچ دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", h)
|
||
.attr("id", "galaxySvg");
|
||
this.svg = svg;
|
||
svg
|
||
.append("rect")
|
||
.attr("width", w)
|
||
.attr("height", h)
|
||
.attr("fill", "#0a0e27");
|
||
for (let i = 0; i < 100; i++) {
|
||
svg
|
||
.append("circle")
|
||
.attr("cx", Math.random() * w)
|
||
.attr("cy", Math.random() * h)
|
||
.attr("r", Math.random() + 0.3)
|
||
.attr("fill", "#fff")
|
||
.attr("opacity", Math.random() * 0.5 + 0.1);
|
||
}
|
||
const defs = svg.append("defs");
|
||
const gf = defs.append("filter").attr("id", "galaxyGlow");
|
||
gf.append("feGaussianBlur")
|
||
.attr("stdDeviation", "3")
|
||
.attr("result", "blur");
|
||
const fm = gf.append("feMerge");
|
||
fm.append("feMergeNode").attr("in", "blur");
|
||
fm.append("feMergeNode").attr("in", "SourceGraphic");
|
||
|
||
this.zoomGroup = svg.append("g");
|
||
this.zoomBehavior = d3
|
||
.zoom()
|
||
.scaleExtent([0.2, 5])
|
||
.on("zoom", (e) => {
|
||
this.zoomGroup.attr("transform", e.transform);
|
||
document.getElementById("galaxyZoomIndicator").textContent =
|
||
`🔍 ${Math.round(e.transform.k * 100)}%`;
|
||
});
|
||
svg.call(this.zoomBehavior);
|
||
|
||
const sim = d3
|
||
.forceSimulation(nodes)
|
||
.force(
|
||
"link",
|
||
d3
|
||
.forceLink(links)
|
||
.id((d) => d.id)
|
||
.distance((d) => (d.type === "ownership" ? 100 : 160)),
|
||
)
|
||
.force("charge", d3.forceManyBody().strength(-300))
|
||
.force("center", d3.forceCenter(w / 2, h / 2));
|
||
|
||
this.linkElements = this.zoomGroup
|
||
.append("g")
|
||
.selectAll("line")
|
||
.data(links)
|
||
.join("line")
|
||
.attr("stroke", (d) =>
|
||
d.type === "dependency" ? "#e0c789" : "#4a5568",
|
||
)
|
||
.attr("stroke-width", (d) => (d.type === "dependency" ? 2 : 1))
|
||
.attr("stroke-opacity", 0.6)
|
||
.attr("stroke-dasharray", (d) =>
|
||
d.type === "dependency" ? "5,3" : "none",
|
||
);
|
||
|
||
const node = this.zoomGroup
|
||
.append("g")
|
||
.selectAll("g")
|
||
.data(nodes)
|
||
.join("g")
|
||
.style("cursor", "pointer")
|
||
.call(
|
||
d3
|
||
.drag()
|
||
.on("start", (e, d) => {
|
||
if (!e.active) sim.alphaTarget(0.3).restart();
|
||
d.fx = d.x;
|
||
d.fy = d.y;
|
||
})
|
||
.on("drag", (e, d) => {
|
||
d.fx = e.x;
|
||
d.fy = e.y;
|
||
})
|
||
.on("end", (e, d) => {
|
||
if (!e.active) sim.alphaTarget(0);
|
||
d.fx = null;
|
||
d.fy = null;
|
||
}),
|
||
);
|
||
this.nodeElements = node;
|
||
|
||
node
|
||
.append("circle")
|
||
.attr("r", (d) => d.radius)
|
||
.attr("fill", (d) =>
|
||
d.type === "product"
|
||
? this.NODE_COLORS.product
|
||
: this.NODE_COLORS[d.projectType] || "#718096",
|
||
)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 1.5)
|
||
.attr("filter", "url(#galaxyGlow)");
|
||
|
||
node
|
||
.append("text")
|
||
.attr("dy", (d) => d.radius + 14)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "#fff")
|
||
.attr("font-size", "10px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.text((d) => d.label);
|
||
|
||
// NEW: نشانگر تعداد پروژهها روی نود کولاپسشده
|
||
node
|
||
.filter((d) => d.type === "product" && d.collapsed)
|
||
.append("text")
|
||
.attr("class", "galaxy-collapse-badge")
|
||
.attr("dy", 4)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "#fff")
|
||
.attr("font-size", "11px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => `+${d.collapsedCount}`);
|
||
|
||
// کلیک = نمایش پنل اطلاعات / دوبارکلیک = کولاپس
|
||
node.on("click", (e, d) => {
|
||
this.showNodeInfo(d);
|
||
});
|
||
node.on("dblclick", (e, d) => {
|
||
if (d.type === "product") {
|
||
e.stopPropagation();
|
||
this.toggleCollapseProduct(d.id);
|
||
}
|
||
});
|
||
|
||
sim.on("tick", () => {
|
||
this.linkElements
|
||
.attr("x1", (d) => d.source.x)
|
||
.attr("y1", (d) => d.source.y)
|
||
.attr("x2", (d) => d.target.x)
|
||
.attr("y2", (d) => d.target.y);
|
||
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
||
});
|
||
|
||
this.renderLegend();
|
||
this.updateStats(nodes, links);
|
||
this.setupSearch();s
|
||
},
|
||
|
||
getTypeLabel(t) {
|
||
const i = VALID_PROJECT_TYPES.find((x) => x.value === t);
|
||
return i ? i.label.split(" ")[0] : t;
|
||
},
|
||
|
||
renderLegend() {
|
||
const l = document.getElementById("galaxyLegend");
|
||
let h = '<div class="galaxy-legend-title">🎨 راهنما</div>';
|
||
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS.product}"></div><span>📦 محصول</span></div>`;
|
||
VALID_PROJECT_TYPES.forEach((t) => {
|
||
h += `<div class="galaxy-legend-item"><div class="galaxy-legend-color" style="background:${this.NODE_COLORS[t.value]}"></div><span>${t.icon} ${t.label.split(" ")[0]}</span></div>`;
|
||
});
|
||
l.innerHTML = h;
|
||
},
|
||
|
||
updateStats(nodes, links) {
|
||
const p = projectData.appData.products.length;
|
||
const pr = projectData.appData.products.reduce(
|
||
(x, p) => x + p.projects.length,
|
||
0,
|
||
);
|
||
const dp = links.filter((l) => l.type === "dependency").length;
|
||
document.getElementById("galaxyStats").innerHTML =
|
||
`<span>📦 ${p}</span><span>🌿 ${pr}</span><span>🔗 ${dp}</span>`;
|
||
},
|
||
|
||
/* ── NEW: پنل اطلاعات دقیق با دکمه کولاپس ── */
|
||
showNodeInfo(d) {
|
||
const p = document.getElementById("galaxyInfoPanel");
|
||
const ic = document.getElementById("galaxyInfoContent");
|
||
let ih = "";
|
||
if (d.type === "product") {
|
||
const projects = d.data.projects || [];
|
||
const isCollapsed = this.collapsedProducts.has(d.id);
|
||
ih += `<h4>📦 ${d.label}</h4>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">توضیحات</span><span class="galaxy-info-value">${d.data.description || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">مالک</span><span class="galaxy-info-value">${d.data.metadata?.owner || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">تعداد پروژه</span><span class="galaxy-info-value">${projects.length}</span></div>`;
|
||
if (projects.length > 0) {
|
||
ih += `<div style="margin-top:10px;border-top:1px solid rgba(255,255,255,.1);padding-top:8px"><div style="font-weight:700;font-size:11px;margin-bottom:6px">🌿 پروژهها:</div>`;
|
||
projects.forEach((pr) => {
|
||
const ti = this.getTypeInfo(pr.type);
|
||
ih += `<div style="font-size:11px;padding:3px 0;color:rgba(255,255,255,.8)">${ti.icon} ${pr.title}</div>`;
|
||
});
|
||
ih += `</div>`;
|
||
}
|
||
ih += `<div style="margin-top:12px;border-top:1px solid rgba(255,255,255,.1);padding-top:10px">`;
|
||
// ih += `<button onclick="GalaxyChartManager.toggleCollapseProduct('${d.id}')" style="width:100%;padding:8px;background:${isCollapsed ? '#38a169' : '#dd6b20'};color:#fff;border:none;border-radius:6px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600">${isCollapsed ? '📂 بازکردن پروژهها' : '📁 جمعکردن پروژهها'}</button>`;
|
||
ih += `</div>`;
|
||
} else {
|
||
const ti = this.getTypeInfo(d.projectType);
|
||
const deploys = ProductManager.getActiveDeployments(d.data);
|
||
ih += `<h4>${ti.icon} ${d.label}</h4>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">نوع</span><span class="galaxy-info-value">${ti.label}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">فریمورک</span><span class="galaxy-info-value">${d.data.metadata?.framework || "—"}</span></div>`;
|
||
ih += `<div class="galaxy-info-row"><span class="galaxy-info-label">استقرارها</span><span class="galaxy-info-value">${deploys.length}</span></div>`;
|
||
}
|
||
ic.innerHTML = ih;
|
||
p.classList.add("visible");
|
||
},
|
||
|
||
getTypeInfo(type) {
|
||
return (
|
||
VALID_PROJECT_TYPES.find((t) => t.value === type) || {
|
||
value: type,
|
||
label: type,
|
||
icon: "❓",
|
||
}
|
||
);
|
||
},
|
||
|
||
/* ── جستجو در نمودار ── */
|
||
setupSearch() {
|
||
const inp = document.getElementById("galaxySearchInput");
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
if (!inp || !clr) return;
|
||
inp.value = "";
|
||
clr.classList.remove("visible");
|
||
inp.oninput = () => this.applySearch(inp.value.trim());
|
||
inp.onkeydown = (e) => {
|
||
if (e.key === "Escape" && inp.value) {
|
||
e.stopPropagation();
|
||
this.clearSearch();
|
||
inp.focus();
|
||
}
|
||
};
|
||
clr.onclick = () => {
|
||
this.clearSearch();
|
||
inp.focus();
|
||
};
|
||
},
|
||
|
||
applySearch(query) {
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
const results = document.getElementById("galaxySearchResults");
|
||
if (clr) clr.classList.toggle("visible", !!query);
|
||
if (!query) {
|
||
this.clearSearch();
|
||
return;
|
||
}
|
||
|
||
const q = query.toLowerCase();
|
||
const matches = this.allNodes.filter((n) => {
|
||
const hay = [
|
||
n.label,
|
||
n.productTitle || "",
|
||
n.data?.description || "",
|
||
n.data?.metadata?.framework || "",
|
||
n.data?.metadata?.language || "",
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
|
||
// هایلایت نودهای منطبق
|
||
const matchIds = new Set(matches.map((m) => m.id));
|
||
if (this.nodeElements) {
|
||
this.nodeElements
|
||
.classed("galaxy-dim", (d) => !matchIds.has(d.id))
|
||
.classed("galaxy-match", (d) => matchIds.has(d.id));
|
||
}
|
||
if (this.linkElements) {
|
||
this.linkElements.classed("galaxy-dim", (l) => {
|
||
const s = typeof l.source === "object" ? l.source.id : l.source;
|
||
const t = typeof l.target === "object" ? l.target.id : l.target;
|
||
return !(matchIds.has(s) || matchIds.has(t));
|
||
});
|
||
}
|
||
|
||
// نمایش لیست نتایج
|
||
if (!results) return;
|
||
if (matches.length === 0) {
|
||
results.innerHTML =
|
||
'<div class="galaxy-search-empty">نتیجهای یافت نشد</div>';
|
||
} else {
|
||
results.innerHTML = matches
|
||
.slice(0, 30)
|
||
.map((n) => {
|
||
const icon =
|
||
n.type === "product"
|
||
? "📦"
|
||
: ProductManager.getTypeInfo(n.projectType)?.icon || "❓";
|
||
const sub =
|
||
n.type === "product"
|
||
? "محصول"
|
||
: ProductManager.getTypeInfo(n.projectType)?.label || "";
|
||
return `<div class="galaxy-search-result-item" data-node-id="${n.id}">
|
||
<span class="galaxy-search-result-icon">${icon}</span>
|
||
<div class="galaxy-search-result-text">
|
||
<div class="galaxy-search-result-title">${n.label}</div>
|
||
<div class="galaxy-search-result-sub">${sub}</div>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
results
|
||
.querySelectorAll(".galaxy-search-result-item")
|
||
.forEach((item) => {
|
||
item.addEventListener("click", () => {
|
||
this.focusNode(item.dataset.nodeId);
|
||
results.classList.remove("visible");
|
||
});
|
||
});
|
||
}
|
||
results.classList.add("visible");
|
||
},
|
||
|
||
clearSearch() {
|
||
const inp = document.getElementById("galaxySearchInput");
|
||
const clr = document.getElementById("galaxySearchClear");
|
||
const results = document.getElementById("galaxySearchResults");
|
||
if (inp) inp.value = "";
|
||
if (clr) clr.classList.remove("visible");
|
||
if (results) {
|
||
results.innerHTML = "";
|
||
results.classList.remove("visible");
|
||
}
|
||
this.allNodes.forEach((n) => (n._matched = false));
|
||
if (this.nodeElements)
|
||
this.nodeElements
|
||
.classed("galaxy-dim", false)
|
||
.classed("galaxy-match", false)
|
||
.classed("galaxy-focus", false);
|
||
if (this.linkElements) this.linkElements.classed("galaxy-dim", false);
|
||
},
|
||
|
||
/* ── زوم و مرکز کردن روی گره ── */
|
||
focusNode(nodeId) {
|
||
const n = this.allNodes.find((x) => x.id === nodeId);
|
||
if (!n || !this.svg || !this.zoomBehavior) return;
|
||
const c = document.getElementById("galaxyChartContainer");
|
||
const w = c.clientWidth || 900,
|
||
h = c.clientHeight || 600;
|
||
const scale = 1.6;
|
||
const t = d3.zoomIdentity
|
||
.translate(w / 2 - scale * (n.x || 0), h / 2 - scale * (n.y || 0))
|
||
.scale(scale);
|
||
this.svg
|
||
.transition()
|
||
.duration(600)
|
||
.call(this.zoomBehavior.transform, t);
|
||
this.showNodeInfo(n);
|
||
if (this.nodeElements) {
|
||
this.nodeElements.classed("galaxy-focus", (d) => d.id === nodeId);
|
||
setTimeout(() => {
|
||
if (this.nodeElements)
|
||
this.nodeElements.classed("galaxy-focus", false);
|
||
}, 2000);
|
||
}
|
||
},
|
||
};
|
||
/* ==================== ANALYSIS MANAGERS ==================== */
|
||
const DependencyAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("dependencyAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
|
||
getData() {
|
||
let totalDeps = 0,
|
||
totalProjects = 0,
|
||
brokenDeps = 0;
|
||
const allProjects = [];
|
||
const inDeps = {};
|
||
const typeCounts = {};
|
||
const matrix = {};
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
prod.projects.forEach((pr) => {
|
||
allProjects.push({ ...pr, productName: prod.title });
|
||
totalProjects++;
|
||
matrix[pr.id] = {};
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
totalDeps++;
|
||
if (!ProductManager.findProjectById(depId)) {
|
||
brokenDeps++;
|
||
} else {
|
||
inDeps[depId] = (inDeps[depId] || 0) + 1;
|
||
matrix[pr.id][depId] = true;
|
||
}
|
||
typeCounts[pr.type] = (typeCounts[pr.type] || 0) + 1;
|
||
});
|
||
});
|
||
});
|
||
|
||
const bottlenecks = Object.entries(inDeps)
|
||
.filter(([k, v]) => v >= 2)
|
||
.map(([id, count]) => ({
|
||
project: ProductManager.findProjectById(id),
|
||
count,
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
const isolated = allProjects.filter(
|
||
(pr) =>
|
||
(pr.dependencies || []).length === 0 && !(inDeps[pr.id] > 0),
|
||
);
|
||
|
||
return {
|
||
totalDeps,
|
||
totalProjects,
|
||
brokenDeps,
|
||
bottlenecks,
|
||
isolated,
|
||
typeCounts,
|
||
matrix,
|
||
allProjects,
|
||
inDeps,
|
||
};
|
||
},
|
||
|
||
render() {
|
||
const c = document.getElementById("dependencyAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔗</div><div class="dashboard-kpi-value">${d.totalDeps}</div><div class="dashboard-kpi-label">کل وابستگیها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.totalProjects}</div><div class="dashboard-kpi-label">پروژهها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.bottlenecks.length}</div><div class="dashboard-kpi-label">گلوگاه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.brokenDeps}</div><div class="dashboard-kpi-label">شکسته</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.isolated.length}</div><div class="dashboard-kpi-label">ایزوله</div></div>`;
|
||
h += "</div>";
|
||
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 گلوگاهها (بیشترین وابستگی ورودی)</div><div class="dashboard-chart-body" id="depHubChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🥧 توزیع نوع پروژههای وابسته</div><div class="dashboard-chart-body" id="depTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">🟥 ماتریس وابستگی</div><div class="dashboard-chart-body" id="depMatrixChart" style="overflow:auto;max-height:300px"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="depRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
|
||
c.innerHTML = h;
|
||
|
||
this.renderHubChart("depHubChart", d.bottlenecks);
|
||
this.renderTypeDonut("depTypeChart", d.typeCounts);
|
||
this.renderMatrix("depMatrixChart", d.allProjects, d.matrix);
|
||
this.renderRiskList("depRiskList", d);
|
||
|
||
document.getElementById("depAnalysisFooterStats").innerHTML =
|
||
`<span>🔗 کل: <span class="stat-highlight">${d.totalDeps}</span></span>` +
|
||
`<span>⚠️ گلوگاه: <span class="stat-highlight">${d.bottlenecks.length}</span></span>` +
|
||
`<span>🔴 شکسته: <span class="stat-highlight">${d.brokenDeps}</span></span>` +
|
||
`<span>🔵 ایزوله: <span class="stat-highlight">${d.isolated.length}</span></span>`;
|
||
|
||
document.getElementById("depAnalysisRefresh").onclick = () =>
|
||
this.render();
|
||
},
|
||
|
||
renderHubChart(cid, bottlenecks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!bottlenecks || bottlenecks.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>گلوگاهی نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = Math.max(120, bottlenecks.length * 40 + 40);
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(bottlenecks, (d) => d.count) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(bottlenecks.map((d) => (d.project ? d.project.title : "")))
|
||
.range([0, ih])
|
||
.padding(0.3);
|
||
g.selectAll("rect")
|
||
.data(bottlenecks)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (d) => y(d.project ? d.project.title : ""))
|
||
.attr("width", (d) => x(d.count))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", (d) => (d.count >= 3 ? "#e53e3e" : "#dd6b20"))
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.project ? d.project.title : ""}: ${d.count} وابستگی ورودی`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, d) {
|
||
d3.select(this).attr(
|
||
"fill",
|
||
d.count >= 3 ? "#e53e3e" : "#dd6b20",
|
||
);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".label")
|
||
.data(bottlenecks)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr(
|
||
"y",
|
||
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
|
||
)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "600")
|
||
.text((d) => {
|
||
const t = d.project ? d.project.title : "";
|
||
return t.length > 18 ? t.substring(0, 18) + "..." : t;
|
||
});
|
||
g.selectAll(".value")
|
||
.data(bottlenecks)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.count) + 5)
|
||
.attr(
|
||
"y",
|
||
(d) => y(d.project ? d.project.title : "") + y.bandwidth() / 2,
|
||
)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.count);
|
||
},
|
||
|
||
renderTypeDonut(cid, typeCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const entries = Object.entries(typeCounts);
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🥧</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d[1])
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = entries.reduce((s, d) => s + d[1], 0);
|
||
const colors = {
|
||
backend: "#4299e1",
|
||
frontend: "#48bb78",
|
||
service: "#ecc94b",
|
||
database: "#ed64a6",
|
||
mobile: "#9f7aea",
|
||
infrastructure: "#ed8936",
|
||
};
|
||
g.selectAll("path")
|
||
.data(pie(entries))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => colors[d.data[0]] || "#718096")
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
const ti = VALID_PROJECT_TYPES.find((t) => t.value === d.data[0]);
|
||
showD3Tooltip(
|
||
e,
|
||
`${ti ? ti.label : d.data[0]}: ${d.data[1]} (${Math.round((d.data[1] / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(total);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("وابستگی");
|
||
},
|
||
|
||
renderMatrix(cid, allProjects, matrix) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
if (!allProjects || allProjects.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>🟥</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const cs = 28,
|
||
lw = 140;
|
||
const w = lw + allProjects.length * cs + 20;
|
||
const hgt = 40 + allProjects.length * cs + 20;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg.append("g");
|
||
allProjects.forEach((pr, i) => {
|
||
g.append("text")
|
||
.attr("x", lw - 8)
|
||
.attr("y", 40 + i * cs + cs / 2)
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.text(
|
||
pr.title.length > 20
|
||
? pr.title.substring(0, 20) + "..."
|
||
: pr.title,
|
||
);
|
||
});
|
||
allProjects.forEach((pr, i) => {
|
||
g.append("text")
|
||
.attr("x", lw + i * cs + cs / 2)
|
||
.attr("y", 30)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", `rotate(-45, ${lw + i * cs + cs / 2}, 30)`)
|
||
.text(
|
||
pr.title.length > 12
|
||
? pr.title.substring(0, 12) + "..."
|
||
: pr.title,
|
||
);
|
||
});
|
||
allProjects.forEach((row, i) => {
|
||
allProjects.forEach((col, j) => {
|
||
const hasDep = matrix[row.id] && matrix[row.id][col.id];
|
||
g.append("rect")
|
||
.attr("x", lw + j * cs)
|
||
.attr("y", 40 + i * cs)
|
||
.attr("width", cs - 2)
|
||
.attr("height", cs - 2)
|
||
.attr("rx", 3)
|
||
.attr("fill", hasDep ? "#e53e3e" : "var(--border-light)")
|
||
.attr("opacity", hasDep ? 0.9 : 0.5)
|
||
.style("cursor", hasDep ? "pointer" : "default")
|
||
.on(
|
||
"mouseover",
|
||
hasDep
|
||
? (e) => showD3Tooltip(e, `${row.title} → ${col.title}`)
|
||
: null,
|
||
)
|
||
.on("mousemove", hasDep ? (e) => showD3Tooltip(e, "") : null)
|
||
.on("mouseout", hasDep ? () => hideD3Tooltip() : null);
|
||
});
|
||
});
|
||
},
|
||
|
||
renderRiskList(cid, d) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
let h = "";
|
||
if (d.brokenDeps > 0) {
|
||
h += `<div style="padding:10px 12px;background:rgba(229,62,62,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #e53e3e"><div style="font-weight:700;font-size:12px">🔴 وابستگیهای شکسته</div><div style="font-size:11px;color:var(--text-muted)">${d.brokenDeps} وابستگی به پروژههای ناموجود</div></div>`;
|
||
}
|
||
d.bottlenecks.forEach((b) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(221,107,32,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #dd6b20"><div style="font-weight:700;font-size:12px">⚠️ گلوگاه: ${b.project ? b.project.title : ""}</div><div style="font-size:11px;color:var(--text-muted)">${b.count} پروژه به این پروژه وابستهاند - ریسک بالا در صورت تغییر</div></div>`;
|
||
});
|
||
if (d.isolated.length > 0) {
|
||
h += `<div style="padding:10px 12px;background:rgba(49,130,206,.08);border-radius:6px;margin-bottom:8px;border-right:4px solid #3182ce"><div style="font-weight:700;font-size:12px">🔵 پروژههای ایزوله</div><div style="font-size:11px;color:var(--text-muted)">${d.isolated.map((p) => p.title).join("، ")}</div></div>`;
|
||
}
|
||
if (!h)
|
||
h =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const DeploymentAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("deploymentAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
|
||
getData() {
|
||
let total = 0,
|
||
pwd = 0,
|
||
pwod = 0,
|
||
withNotes = 0,
|
||
withoutNotes = 0;
|
||
const edc = {};
|
||
const ppc = [];
|
||
const riskList = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
let prodDeployCount = 0;
|
||
prod.projects.forEach((pr) => {
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
if (active.length > 0) pwd++;
|
||
else {
|
||
pwod++;
|
||
riskList.push({
|
||
type: "no-deploy",
|
||
severity: "high",
|
||
title: "بدون استقرار",
|
||
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
|
||
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
|
||
});
|
||
}
|
||
active.forEach((d) => {
|
||
total++;
|
||
prodDeployCount++;
|
||
if (d.releaseNotes) withNotes++;
|
||
else {
|
||
withoutNotes++;
|
||
riskList.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
}
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
edc[envName] = (edc[envName] || 0) + 1;
|
||
if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < thirtyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale",
|
||
severity: daysOld > 90 ? "high" : "medium",
|
||
title: "استقرار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "بررسی و بهروزرسانی نسخه",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
ppc.push({ title: prod.title, count: prodDeployCount });
|
||
});
|
||
|
||
const coverage =
|
||
pwd + pwod > 0 ? Math.round((pwd / (pwd + pwod)) * 100) : 0;
|
||
return {
|
||
total,
|
||
pwd,
|
||
pwod,
|
||
withNotes,
|
||
withoutNotes,
|
||
edc,
|
||
ppc,
|
||
riskList,
|
||
coverage,
|
||
};
|
||
},
|
||
|
||
render() {
|
||
const c = document.getElementById("deploymentAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🚀</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل استقرارها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">✅</div><div class="dashboard-kpi-value">${d.pwd}</div><div class="dashboard-kpi-label">با استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">❌</div><div class="dashboard-kpi-value">${d.pwod}</div><div class="dashboard-kpi-label">بدون استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value">${d.coverage}%</div><div class="dashboard-kpi-label">پوشش استقرار</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📝</div><div class="dashboard-kpi-value">${d.withNotes}</div><div class="dashboard-kpi-label">با ریلیز نوت</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.withoutNotes}</div><div class="dashboard-kpi-label">بدون ریلیز نوت</div></div>`;
|
||
h += "</div>";
|
||
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 استقرارها به تفکیک محیط</div><div class="dashboard-chart-body" id="deployEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">✅ وضعیت استقرار پروژهها</div><div class="dashboard-chart-body" id="deployStatusChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 استقرارها به تفکیک محصول</div><div class="dashboard-chart-body" id="deployProductChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="deployRiskList" style="align-items:flex-start;overflow-y:auto;max-height:280px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
|
||
c.innerHTML = h;
|
||
|
||
this.renderEnvBarChart("deployEnvChart", d.edc);
|
||
this.renderStatusDonut(
|
||
"deployStatusChart",
|
||
d.pwd,
|
||
d.pwod,
|
||
d.coverage,
|
||
);
|
||
this.renderProductBarChart("deployProductChart", d.ppc);
|
||
this.renderRiskList("deployRiskList", d.riskList);
|
||
|
||
document.getElementById("deployAnalysisFooterStats").innerHTML =
|
||
`<span>🚀 کل: <span class="stat-highlight">${d.total}</span></span>` +
|
||
`<span>📈 پوشش: <span class="stat-highlight">${d.coverage}%</span></span>` +
|
||
`<span>⚠️ ریسکها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
|
||
|
||
document.getElementById("deployAnalysisRefresh").onclick = () =>
|
||
this.render();
|
||
},
|
||
|
||
renderEnvBarChart(cid, edc) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const data = Object.entries(edc).map(([k, v]) => ({
|
||
label: k,
|
||
value: v,
|
||
}));
|
||
if (data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 45, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(data.map((d) => d.label))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (d) => d.value) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", (d) => x(d.label))
|
||
.attr("y", (d) => y(d.value))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (d) => ih - y(d.value))
|
||
.attr("fill", "#38a169")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d.label}: ${d.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#38a169");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.label) + x.bandwidth() / 2)
|
||
.attr("y", (d) => y(d.value) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.value);
|
||
},
|
||
|
||
renderStatusDonut(cid, pwd, pwod, coverage) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const total = pwd + pwod;
|
||
if (total === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((d) => d.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const data = [
|
||
{ label: "با استقرار", value: pwd, color: "#38a169" },
|
||
{ label: "بدون استقرار", value: pwod, color: "#e53e3e" },
|
||
].filter((d) => d.value > 0);
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (d) => d.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(d));
|
||
showD3Tooltip(
|
||
e,
|
||
`${d.data.label}: ${d.data.value} (${Math.round((d.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr(
|
||
"fill",
|
||
coverage >= 70
|
||
? "#38a169"
|
||
: coverage >= 40
|
||
? "#dd6b20"
|
||
: "#e53e3e",
|
||
)
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(`${coverage}%`);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("پوشش استقرار");
|
||
},
|
||
|
||
renderProductBarChart(cid, ppc) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const data = ppc.map((p) => ({
|
||
label:
|
||
p.title.length > 15 ? p.title.substring(0, 15) + "..." : p.title,
|
||
value: p.count,
|
||
}));
|
||
if (data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const bh = 28,
|
||
gap = 6;
|
||
const w = 320,
|
||
hgt = data.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(data, (d) => d.value) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(data.map((d) => d.label))
|
||
.range([0, ih])
|
||
.padding(0.2);
|
||
g.selectAll("rect")
|
||
.data(data)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (d) => y(d.label))
|
||
.attr("width", (d) => x(d.value))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", "#3182ce")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, d) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${d.label}: ${d.value}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#3182ce");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".label")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (d) => y(d.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((d) => d.label);
|
||
g.selectAll(".value")
|
||
.data(data)
|
||
.join("text")
|
||
.attr("x", (d) => x(d.value) + 5)
|
||
.attr("y", (d) => y(d.label) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((d) => d.value);
|
||
},
|
||
|
||
renderRiskList(cid, riskList) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (riskList.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const colors = {
|
||
critical: "#e53e3e",
|
||
high: "#dd6b20",
|
||
medium: "#d69e2e",
|
||
};
|
||
let h = "";
|
||
riskList.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const VersionAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("versionAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
|
||
/* ── جمعآوری دادههای جامع نسخهها ── */
|
||
getData() {
|
||
let total = 0;
|
||
const uniqueVersions = new Set();
|
||
const majorCounts = {};
|
||
const envCounts = {};
|
||
const envMatrix = {};
|
||
const riskList = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const ninetyDaysAgo = new Date(
|
||
now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const prereleaseRegex = /-(rc|beta|dev|alpha)/i;
|
||
|
||
// مقداردهی اولیه محیطها
|
||
projectData.appData.environments.forEach((e) => {
|
||
envCounts[e.name] = 0;
|
||
});
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
(prod.projects || []).forEach((pr) => {
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
|
||
// مقداردهی اولیه ماتریس
|
||
envMatrix[pr.title] = {};
|
||
|
||
active.forEach((d) => {
|
||
total++;
|
||
uniqueVersions.add(d.version);
|
||
|
||
// استخراج Major
|
||
const major = parseInt(d.version.split(".")[0]) || 0;
|
||
const majorKey = `v${major}.x`;
|
||
majorCounts[majorKey] = (majorCounts[majorKey] || 0) + 1;
|
||
|
||
// شمارش به تفکیک محیط
|
||
const env = projectData.appData.environments.find(
|
||
(e) => e.id === d.environmentId,
|
||
);
|
||
const envName = env ? env.name : "نامشخص";
|
||
envCounts[envName] = (envCounts[envName] || 0) + 1;
|
||
|
||
// پر کردن ماتریس
|
||
envMatrix[pr.title][d.environmentId] = d.version;
|
||
|
||
// ریسک ۱: نسخههای بسیار قدیمی (+۹۰ روز)
|
||
if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < ninetyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale-critical",
|
||
severity: "high",
|
||
icon: "⏰",
|
||
title: "نسخه بسیار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "فوراً بررسی و بهروزرسانی کنید",
|
||
});
|
||
}
|
||
// ریسک ۲: نسخههای قدیمی (۳۰-۹۰ روز)
|
||
else if (
|
||
d.deploymentDate &&
|
||
new Date(d.deploymentDate) < thirtyDaysAgo
|
||
) {
|
||
const daysOld = Math.floor(
|
||
(now - new Date(d.deploymentDate)) / (1000 * 60 * 60 * 24),
|
||
);
|
||
riskList.push({
|
||
type: "stale-medium",
|
||
severity: "medium",
|
||
icon: "⏰",
|
||
title: "نسخه قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "برنامهریزی بهروزرسانی",
|
||
});
|
||
}
|
||
|
||
// ریسک ۳: بدون ریلیز نوت
|
||
if (!d.releaseNotes) {
|
||
riskList.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
icon: "📝",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
}
|
||
|
||
// ریسک ۴: نسخه پیشنویس در Production
|
||
if (prereleaseRegex.test(d.version)) {
|
||
if (d.environmentId === "env-prod-001") {
|
||
riskList.push({
|
||
type: "prerelease-prod",
|
||
severity: "high",
|
||
icon: "🧪",
|
||
title: "نسخه پیشنویس در Production",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "نسخه پایدار را در Production مستقر کنید",
|
||
});
|
||
} else {
|
||
riskList.push({
|
||
type: "prerelease-other",
|
||
severity: "low",
|
||
icon: "🧪",
|
||
title: "نسخه پیشنویس",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "پس از تست، نسخه پایدار را منتشر کنید",
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// محاسبه میانگین نسخه
|
||
let avgMajor = 0,
|
||
avgMinor = 0,
|
||
avgPatch = 0;
|
||
if (uniqueVersions.size > 0) {
|
||
let sumMajor = 0,
|
||
sumMinor = 0,
|
||
sumPatch = 0;
|
||
uniqueVersions.forEach((v) => {
|
||
const parts = v.split(".");
|
||
sumMajor += parseInt(parts[0]) || 0;
|
||
sumMinor += parseInt(parts[1]) || 0;
|
||
sumPatch += parseInt(parts[2]) || 0;
|
||
});
|
||
avgMajor = Math.round(sumMajor / uniqueVersions.size);
|
||
avgMinor = Math.round(sumMinor / uniqueVersions.size);
|
||
avgPatch = Math.round(sumPatch / uniqueVersions.size);
|
||
}
|
||
|
||
const prereleaseCount = [...uniqueVersions].filter((v) =>
|
||
prereleaseRegex.test(v),
|
||
).length;
|
||
|
||
return {
|
||
total,
|
||
uniqueVersions: uniqueVersions.size,
|
||
avgVersion: `${avgMajor}.${avgMinor}.${avgPatch}`,
|
||
prereleaseCount,
|
||
majorCounts,
|
||
envCounts,
|
||
envMatrix,
|
||
riskList,
|
||
};
|
||
},
|
||
|
||
/* ── رندر اصلی مودال ── */
|
||
render() {
|
||
const c = document.getElementById("versionAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.getData();
|
||
|
||
// ۵ کارت KPI
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔢</div><div class="dashboard-kpi-value">${d.total}</div><div class="dashboard-kpi-label">کل نسخهها</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📦</div><div class="dashboard-kpi-value">${d.uniqueVersions}</div><div class="dashboard-kpi-label">نسخههای یکتا</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">📈</div><div class="dashboard-kpi-value" style="font-size:20px">${d.avgVersion}</div><div class="dashboard-kpi-label">میانگین نسخه</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🧪</div><div class="dashboard-kpi-value">${d.prereleaseCount}</div><div class="dashboard-kpi-label">پیشنویس</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">⚠️</div><div class="dashboard-kpi-value">${d.riskList.length}</div><div class="dashboard-kpi-label">ریسکها</div></div>`;
|
||
h += "</div>";
|
||
|
||
// نمودارها
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نسخهها بر اساس Major</div><div class="dashboard-chart-body" id="verMajorChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🌍 نسخهها به تفکیک محیط</div><div class="dashboard-chart-body" id="verEnvChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">📋 مقایسه نسخهها بین محیطها</div><div class="dashboard-chart-body" id="verEnvMatrix" style="overflow:auto;max-height:300px;width:100%"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها</div><div class="dashboard-chart-body" id="verRiskList" style="align-items:flex-start;overflow-y:auto;max-height:300px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
|
||
c.innerHTML = h;
|
||
|
||
this.renderMajorBarChart("verMajorChart", d.majorCounts);
|
||
this.renderEnvBarChart("verEnvChart", d.envCounts);
|
||
this.renderEnvMatrix("verEnvMatrix", d.envMatrix);
|
||
this.renderRiskList("verRiskList", d.riskList);
|
||
|
||
document.getElementById("verAnalysisFooterStats").innerHTML =
|
||
`<span>🔢 کل: <span class="stat-highlight">${d.total}</span></span>` +
|
||
`<span>📦 یکتا: <span class="stat-highlight">${d.uniqueVersions}</span></span>` +
|
||
`<span>📈 میانگین: <span class="stat-highlight">${d.avgVersion}</span></span>` +
|
||
`<span>⚠️ ریسکها: <span class="stat-highlight">${d.riskList.length}</span></span>`;
|
||
|
||
document.getElementById("verAnalysisRefresh").onclick = () =>
|
||
this.render();
|
||
},
|
||
|
||
/* ── نمودار میلهای توزیع بر اساس Major ── */
|
||
renderMajorBarChart(cid, majorCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const entries = Object.entries(majorCounts).sort((a, b) =>
|
||
a[0].localeCompare(b[0]),
|
||
);
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 40, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(entries.map((x) => x[0]))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(entries, (x) => x[1]) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
const colors = [
|
||
"#4299e1",
|
||
"#48bb78",
|
||
"#ecc94b",
|
||
"#ed64a6",
|
||
"#9f7aea",
|
||
"#ed8936",
|
||
];
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-family", "Vazirmatn");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", (x) => x(x[0]))
|
||
.attr("y", (x) => y(x[1]))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (x) => ih - y(x[1]))
|
||
.attr("fill", (x, i) => colors[i % colors.length])
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, x) {
|
||
d3.select(this).attr(
|
||
"fill",
|
||
colors[entries.indexOf(x) % colors.length],
|
||
);
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
|
||
.attr("y", (x) => y(x[1]) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((x) => x[1]);
|
||
},
|
||
|
||
/* ── نمودار میلهای نسخهها به تفکیک محیط ── */
|
||
renderEnvBarChart(cid, envCounts) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const entries = Object.entries(envCounts).filter((x) => x[1] > 0);
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📊</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const w = 320,
|
||
hgt = 220,
|
||
m = { top: 20, right: 15, bottom: 45, left: 30 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(entries.map((x) => x[0]))
|
||
.range([0, iw])
|
||
.padding(0.3);
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(entries, (x) => x[1]) || 1])
|
||
.nice()
|
||
.range([ih, 0]);
|
||
g.append("g")
|
||
.attr("transform", `translate(0,${ih})`)
|
||
.call(d3.axisBottom(x).tickSize(0))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "8px")
|
||
.attr("font-family", "Vazirmatn")
|
||
.attr("transform", "rotate(-20)")
|
||
.attr("text-anchor", "end");
|
||
g.append("g")
|
||
.call(d3.axisLeft(y).ticks(4).tickSize(-iw))
|
||
.selectAll("text")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "9px");
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", (x) => x(x[0]))
|
||
.attr("y", (x) => y(x[1]))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (x) => ih - y(x[1]))
|
||
.attr("fill", "#38a169")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${x[0]}: ${x[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).attr("fill", "#38a169");
|
||
hideD3Tooltip();
|
||
});
|
||
g.selectAll(".bar-label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (x) => x(x[0]) + x.bandwidth() / 2)
|
||
.attr("y", (x) => y(x[1]) - 5)
|
||
.attr("text-anchor", "middle")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((x) => x[1]);
|
||
},
|
||
|
||
/* ── جدول مقایسه نسخهها بین محیطها ── */
|
||
renderEnvMatrix(cid, envMatrix) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const projects = Object.keys(envMatrix);
|
||
if (projects.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>📋</span><div>دادهای نیست</div></div>';
|
||
return;
|
||
}
|
||
const envs = projectData.appData.environments || [];
|
||
let html =
|
||
'<table style="width:100%;border-collapse:collapse;font-size:11px">';
|
||
html += '<thead><tr style="background:var(--border-light)">';
|
||
html +=
|
||
'<th style="padding:8px;text-align:right;border-bottom:2px solid var(--border);font-weight:700">پروژه</th>';
|
||
envs.forEach((env) => {
|
||
html += `<th style="padding:8px;text-align:center;border-bottom:2px solid var(--border);font-weight:700"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${env.backgroundColor};margin-left:4px"></span>${env.name}</th>`;
|
||
});
|
||
html += "</tr></thead><tbody>";
|
||
projects.forEach((prTitle) => {
|
||
html += `<tr style="border-bottom:1px solid var(--border-light)">`;
|
||
html += `<td style="padding:6px 8px;font-weight:600">${prTitle}</td>`;
|
||
envs.forEach((env) => {
|
||
const version = envMatrix[prTitle][env.id];
|
||
if (version) {
|
||
html += `<td style="padding:6px 8px;text-align:center"><span style="font-family:monospace;font-weight:700;color:var(--primary)">v${version}</span></td>`;
|
||
} else {
|
||
html +=
|
||
'<td style="padding:6px 8px;text-align:center;color:var(--text-muted)">—</td>';
|
||
}
|
||
});
|
||
html += "</tr>";
|
||
});
|
||
html += "</tbody></table>";
|
||
c.innerHTML = html;
|
||
},
|
||
|
||
/* ── لیست ریسکها (مرتب بر اساس شدت) ── */
|
||
renderRiskList(cid, riskList) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (riskList.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const severityOrder = { high: 0, medium: 1, low: 2 };
|
||
const sorted = [...riskList].sort(
|
||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
|
||
);
|
||
const colors = { high: "#dd6b20", medium: "#d69e2e", low: "#3182ce" };
|
||
let h = "";
|
||
sorted.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
const RiskAnalysisManager = {
|
||
open() {
|
||
ModalManager.open("riskAnalysisModal");
|
||
setTimeout(() => this.render(), 150);
|
||
},
|
||
|
||
/* ── تحلیل جامع ریسکها (۸ نوع) ── */
|
||
analyzeRisks() {
|
||
const risks = [];
|
||
const now = new Date();
|
||
const thirtyDaysAgo = new Date(
|
||
now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const ninetyDaysAgo = new Date(
|
||
now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const envs = projectData.appData.environments || [];
|
||
const envIdsWithDeployment = new Set();
|
||
|
||
projectData.appData.products.forEach((prod) => {
|
||
// ۱. بدون مالک (medium)
|
||
if (!prod.metadata?.owner) {
|
||
risks.push({
|
||
type: "no-owner",
|
||
severity: "medium",
|
||
icon: "👤",
|
||
title: "بدون مالک",
|
||
desc: `محصول "${prod.title}" مالک مشخصی ندارد`,
|
||
rec: "مالک محصول را مشخص کنید",
|
||
});
|
||
}
|
||
|
||
(prod.projects || []).forEach((pr) => {
|
||
// ۲. وابستگی شکسته (critical)
|
||
(pr.dependencies || []).forEach((depId) => {
|
||
if (!ProductManager.findProjectById(depId)) {
|
||
risks.push({
|
||
type: "broken",
|
||
severity: "critical",
|
||
icon: "🔴",
|
||
title: "وابستگی شکسته",
|
||
desc: `${pr.title} به پروژه ناموجود (${depId}) وابسته است`,
|
||
rec: "وابستگی را حذف یا پروژه مقصد را ایجاد کنید",
|
||
});
|
||
}
|
||
});
|
||
|
||
const active = ProductManager.getActiveDeployments(pr);
|
||
|
||
// ۳. بدون استقرار (high)
|
||
if (active.length === 0) {
|
||
risks.push({
|
||
type: "no-deploy",
|
||
severity: "high",
|
||
icon: "❌",
|
||
title: "بدون استقرار",
|
||
desc: `${pr.title} در هیچ محیطی استقرار ندارد`,
|
||
rec: "نسخه اولیه را در حداقل یک محیط مستقر کنید",
|
||
});
|
||
}
|
||
|
||
active.forEach((d) => {
|
||
envIdsWithDeployment.add(d.environmentId);
|
||
|
||
// ۴. نیازمندی شکسته (critical)
|
||
if (d.requiredDeployments && d.requiredDeployments.length > 0) {
|
||
d.requiredDeployments.forEach((rd) => {
|
||
if (!ProductManager.findProjectById(rd.projectId)) {
|
||
risks.push({
|
||
type: "broken-req",
|
||
severity: "critical",
|
||
icon: "🔴",
|
||
title: "نیازمندی شکسته",
|
||
desc: `${pr.title} به پروژه ناموجود در نیازمندیها ارجاع دارد`,
|
||
rec: "نیازمندی را بررسی و اصلاح کنید",
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// ۵. بدون ریلیز نوت (medium)
|
||
if (!d.releaseNotes) {
|
||
const envName = ProductManager.getEnvironmentName(
|
||
d.environmentId,
|
||
);
|
||
risks.push({
|
||
type: "no-notes",
|
||
severity: "medium",
|
||
icon: "📝",
|
||
title: "بدون ریلیز نوت",
|
||
desc: `${pr.title} v${d.version} در ${envName}`,
|
||
rec: "ریلیز نوت را تکمیل کنید",
|
||
});
|
||
}
|
||
|
||
// ۶ و ۷. استقرار قدیمی
|
||
if (d.deploymentDate) {
|
||
const depDate = new Date(d.deploymentDate);
|
||
const daysOld = Math.floor(
|
||
(now - depDate) / (1000 * 60 * 60 * 24),
|
||
);
|
||
const envName = ProductManager.getEnvironmentName(
|
||
d.environmentId,
|
||
);
|
||
if (depDate < ninetyDaysAgo) {
|
||
risks.push({
|
||
type: "stale-critical",
|
||
severity: "high",
|
||
icon: "⏰",
|
||
title: "استقرار بسیار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "فوراً بررسی و بهروزرسانی کنید",
|
||
});
|
||
} else if (depDate < thirtyDaysAgo) {
|
||
risks.push({
|
||
type: "stale-medium",
|
||
severity: "medium",
|
||
icon: "⏰",
|
||
title: "استقرار قدیمی",
|
||
desc: `${pr.title} v${d.version} در ${envName} (${daysOld} روز)`,
|
||
rec: "برنامهریزی بهروزرسانی",
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// ۸. محیط خالی (low)
|
||
envs.forEach((env) => {
|
||
if (!envIdsWithDeployment.has(env.id)) {
|
||
risks.push({
|
||
type: "empty-environment",
|
||
severity: "low",
|
||
icon: "🌍",
|
||
title: "محیط خالی",
|
||
desc: `محیط "${env.name}" هیچ استقراری ندارد`,
|
||
rec: "در صورت عدم نیاز، محیط را حذف کنید",
|
||
});
|
||
}
|
||
});
|
||
|
||
// شمارش بر اساس شدت
|
||
const crit = risks.filter((r) => r.severity === "critical").length;
|
||
const high = risks.filter((r) => r.severity === "high").length;
|
||
const med = risks.filter((r) => r.severity === "medium").length;
|
||
const low = risks.filter((r) => r.severity === "low").length;
|
||
|
||
// فرمول اصلاحشده Health Score (لحاظ ریسکهای کم)
|
||
let healthScore = 100 - crit * 15 - high * 10 - med * 5 - low * 2;
|
||
healthScore = Math.max(0, healthScore);
|
||
|
||
return {
|
||
risks,
|
||
crit,
|
||
high,
|
||
med,
|
||
low,
|
||
healthScore,
|
||
total: risks.length,
|
||
};
|
||
},
|
||
|
||
/* ── رندر اصلی مودال ── */
|
||
render() {
|
||
const c = document.getElementById("riskAnalysisContainer");
|
||
if (!c) return;
|
||
const d = this.analyzeRisks();
|
||
|
||
// ۵ کارت KPI
|
||
let h = '<div class="dashboard-kpi-row">';
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🎯</div><div class="dashboard-kpi-value">${d.healthScore}%</div><div class="dashboard-kpi-label">امتیاز سلامت</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔴</div><div class="dashboard-kpi-value">${d.crit}</div><div class="dashboard-kpi-label">بحرانی</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟠</div><div class="dashboard-kpi-value">${d.high}</div><div class="dashboard-kpi-label">بالا</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🟡</div><div class="dashboard-kpi-value">${d.med}</div><div class="dashboard-kpi-label">متوسط</div></div>`;
|
||
h += `<div class="dashboard-kpi-card"><div class="dashboard-kpi-icon">🔵</div><div class="dashboard-kpi-value">${d.low}</div><div class="dashboard-kpi-label">کم</div></div>`;
|
||
h += "</div>";
|
||
|
||
// نمودارها و لیست ریسکها
|
||
h += '<div class="dashboard-charts-grid">';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">🍩 توزیع شدت ریسکها</div><div class="dashboard-chart-body" id="riskSeverityChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card"><div class="dashboard-chart-header">📊 توزیع نوع ریسک</div><div class="dashboard-chart-body" id="riskTypeChart"></div></div>';
|
||
h +=
|
||
'<div class="dashboard-chart-card" style="grid-column:span 2"><div class="dashboard-chart-header">⚠️ لیست ریسکها و پیشنهادات</div><div class="dashboard-chart-body" id="riskDetailList" style="align-items:flex-start;overflow-y:auto;max-height:350px;width:100%"></div></div>';
|
||
h += "</div>";
|
||
|
||
c.innerHTML = h;
|
||
|
||
this.renderSeverityDonut("riskSeverityChart", d);
|
||
this.renderTypeBarChart("riskTypeChart", d.risks);
|
||
this.renderRiskList("riskDetailList", d.risks);
|
||
|
||
document.getElementById("riskAnalysisFooterStats").innerHTML =
|
||
`<span>🎯 سلامت: <span class="stat-highlight">${d.healthScore}%</span></span>` +
|
||
`<span>⚠️ ریسکها: <span class="stat-highlight">${d.total}</span></span>` +
|
||
`<span>🔴 بحرانی: <span class="stat-highlight">${d.crit}</span></span>` +
|
||
`<span>🟠 بالا: <span class="stat-highlight">${d.high}</span></span>`;
|
||
|
||
document.getElementById("riskAnalysisRefresh").onclick = () =>
|
||
this.render();
|
||
},
|
||
|
||
/* ── نمودار دونات توزیع شدت ── */
|
||
renderSeverityDonut(cid, d) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
const data = [
|
||
{ label: "بحرانی", value: d.crit, color: "#e53e3e" },
|
||
{ label: "بالا", value: d.high, color: "#dd6b20" },
|
||
{ label: "متوسط", value: d.med, color: "#d69e2e" },
|
||
{ label: "کم", value: d.low, color: "#3182ce" },
|
||
].filter((x) => x.value > 0);
|
||
|
||
if (data.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const w = 280,
|
||
hgt = 220,
|
||
r = Math.min(w, hgt) / 2 - 15;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${w / 2},${hgt / 2})`);
|
||
const pie = d3
|
||
.pie()
|
||
.value((x) => x.value)
|
||
.sort(null);
|
||
const arc = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
const arcH = d3
|
||
.arc()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r + 8);
|
||
const total = data.reduce((s, x) => s + x.value, 0);
|
||
|
||
g.selectAll("path")
|
||
.data(pie(data))
|
||
.join("path")
|
||
.attr("d", arc)
|
||
.attr("fill", (x) => x.data.color)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 2)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).transition().duration(150).attr("d", arcH(x));
|
||
showD3Tooltip(
|
||
e,
|
||
`${x.data.label}: ${x.data.value} (${Math.round((x.data.value / total) * 100)}%)`,
|
||
);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function () {
|
||
d3.select(this).transition().duration(150).attr("d", arc);
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "-.2em")
|
||
.attr(
|
||
"fill",
|
||
d.healthScore >= 70
|
||
? "#38a169"
|
||
: d.healthScore >= 40
|
||
? "#dd6b20"
|
||
: "#e53e3e",
|
||
)
|
||
.attr("font-size", "24px")
|
||
.attr("font-weight", "800")
|
||
.text(`${d.healthScore}%`);
|
||
g.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "1.4em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "11px")
|
||
.text("امتیاز سلامت");
|
||
},
|
||
|
||
/* ── نمودار میلهای افقی توزیع نوع ریسک ── */
|
||
renderTypeBarChart(cid, risks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
c.innerHTML = "";
|
||
|
||
const typeLabels = {
|
||
broken: "وابستگی شکسته",
|
||
"broken-req": "نیازمندی شکسته",
|
||
"no-deploy": "بدون استقرار",
|
||
"stale-critical": "استقرار بسیار قدیمی",
|
||
"stale-medium": "استقرار قدیمی",
|
||
"no-notes": "بدون ریلیز نوت",
|
||
"no-owner": "بدون مالک",
|
||
"empty-environment": "محیط خالی",
|
||
};
|
||
const typeColors = {
|
||
broken: "#e53e3e",
|
||
"broken-req": "#e53e3e",
|
||
"no-deploy": "#dd6b20",
|
||
"stale-critical": "#dd6b20",
|
||
"stale-medium": "#d69e2e",
|
||
"no-notes": "#d69e2e",
|
||
"no-owner": "#d69e2e",
|
||
"empty-environment": "#3182ce",
|
||
};
|
||
|
||
const typeCounts = {};
|
||
risks.forEach((r) => {
|
||
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
|
||
});
|
||
const entries = Object.entries(typeCounts).sort(
|
||
(a, b) => b[1] - a[1],
|
||
);
|
||
|
||
if (entries.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>ریسکی نیست</div></div>';
|
||
return;
|
||
}
|
||
|
||
const bh = 28,
|
||
gap = 6;
|
||
const w = 320,
|
||
hgt = entries.length * (bh + gap) + 30;
|
||
const m = { top: 10, right: 35, bottom: 10, left: 130 };
|
||
const iw = w - m.left - m.right,
|
||
ih = hgt - m.top - m.bottom;
|
||
const svg = d3
|
||
.select(c)
|
||
.append("svg")
|
||
.attr("width", w)
|
||
.attr("height", hgt);
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${m.left},${m.top})`);
|
||
const x = d3
|
||
.scaleLinear()
|
||
.domain([0, d3.max(entries, (x) => x[1]) || 1])
|
||
.range([0, iw]);
|
||
const y = d3
|
||
.scaleBand()
|
||
.domain(entries.map((x) => x[0]))
|
||
.range([0, ih])
|
||
.padding(0.2);
|
||
|
||
g.selectAll("rect")
|
||
.data(entries)
|
||
.join("rect")
|
||
.attr("x", 0)
|
||
.attr("y", (x) => y(x[0]))
|
||
.attr("width", (x) => x(x[1]))
|
||
.attr("height", y.bandwidth())
|
||
.attr("fill", (x) => typeColors[x[0]] || "#718096")
|
||
.attr("rx", 4)
|
||
.style("cursor", "pointer")
|
||
.on("mouseover", function (e, x) {
|
||
d3.select(this).attr("fill", "var(--accent)");
|
||
showD3Tooltip(e, `${typeLabels[x[0]] || x[0]}: ${x[1]}`);
|
||
})
|
||
.on("mousemove", (e) => showD3Tooltip(e, ""))
|
||
.on("mouseout", function (e, x) {
|
||
d3.select(this).attr("fill", typeColors[x[0]] || "#718096");
|
||
hideD3Tooltip();
|
||
});
|
||
|
||
g.selectAll(".label")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", -8)
|
||
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("text-anchor", "end")
|
||
.attr("fill", "var(--text)")
|
||
.attr("font-size", "9px")
|
||
.attr("font-weight", "600")
|
||
.text((x) => typeLabels[x[0]] || x[0]);
|
||
|
||
g.selectAll(".value")
|
||
.data(entries)
|
||
.join("text")
|
||
.attr("x", (x) => x(x[1]) + 5)
|
||
.attr("y", (x) => y(x[0]) + y.bandwidth() / 2)
|
||
.attr("dy", "0.35em")
|
||
.attr("fill", "var(--text-muted)")
|
||
.attr("font-size", "10px")
|
||
.attr("font-weight", "700")
|
||
.text((x) => x[1]);
|
||
},
|
||
|
||
/* ── لیست ریسکها با پیشنهاد رفع (مرتب بر اساس شدت) ── */
|
||
renderRiskList(cid, risks) {
|
||
const c = document.getElementById(cid);
|
||
if (!c) return;
|
||
if (risks.length === 0) {
|
||
c.innerHTML =
|
||
'<div class="dashboard-empty-chart"><span>✅</span><div>هیچ ریسکی شناسایی نشد</div></div>';
|
||
return;
|
||
}
|
||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||
const sorted = [...risks].sort(
|
||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity],
|
||
);
|
||
const colors = {
|
||
critical: "#e53e3e",
|
||
high: "#dd6b20",
|
||
medium: "#d69e2e",
|
||
low: "#3182ce",
|
||
};
|
||
let h = "";
|
||
sorted.forEach((r) => {
|
||
h += `<div style="padding:10px 12px;background:rgba(0,0,0,.05);border-radius:6px;margin-bottom:8px;border-right:4px solid ${colors[r.severity] || "#3182ce"}">`;
|
||
h += `<div style="font-weight:700;font-size:12px">${r.icon} ${r.title}</div>`;
|
||
h += `<div style="font-size:11px;color:var(--text-muted)">${r.desc}</div>`;
|
||
h += `<div style="font-size:10px;color:var(--info);margin-top:4px">💡 ${r.rec}</div>`;
|
||
h += "</div>";
|
||
});
|
||
c.innerHTML = h;
|
||
},
|
||
};
|
||
/* ==================== EVENT BINDINGS ==================== */
|
||
document
|
||
.getElementById("projectDetailDeleteBtn")
|
||
.addEventListener("click", () => {
|
||
if (ProductManager.currentDetailProductId)
|
||
ProductManager.removeProduct(ProductManager.currentDetailProductId);
|
||
});
|
||
document
|
||
.getElementById("saveProductEditBtn")
|
||
.addEventListener("click", () => ProductManager.saveProductEdit());
|
||
document
|
||
.getElementById("saveNewProjectBtn")
|
||
.addEventListener("click", () => ProductManager.saveNewProject());
|
||
document
|
||
.getElementById("saveProjEditBtn")
|
||
.addEventListener("click", () => ProductManager.saveProjectEdit());
|
||
document
|
||
.getElementById("saveDeploymentBtn")
|
||
.addEventListener("click", () => ProductManager.saveDeployment());
|
||
document
|
||
.getElementById("saveEnvEditBtn")
|
||
.addEventListener("click", () => EnvironmentManager.saveEdit());
|
||
document
|
||
.getElementById("saveNewProductBtn")
|
||
.addEventListener("click", () => ProductManager.createNewProduct());
|
||
document
|
||
.getElementById("saveNewEnvBtn")
|
||
.addEventListener("click", () => EnvironmentManager.saveNewEnv());
|
||
document
|
||
.getElementById("newEnvBgColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("newEnvBgColorText").textContent =
|
||
e.target.value;
|
||
});
|
||
document
|
||
.getElementById("newEnvTextColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("newEnvTextColorText").textContent =
|
||
e.target.value;
|
||
});
|
||
document
|
||
.getElementById("editEnvBgColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("editEnvBgColorText").textContent =
|
||
e.target.value;
|
||
EnvironmentManager.updateEditPreview();
|
||
});
|
||
document
|
||
.getElementById("editEnvTextColor")
|
||
.addEventListener("input", (e) => {
|
||
document.getElementById("editEnvTextColorText").textContent =
|
||
e.target.value;
|
||
EnvironmentManager.updateEditPreview();
|
||
});
|
||
document
|
||
.getElementById("galaxyResetView")
|
||
.addEventListener("click", () => {
|
||
if (GalaxyChartManager.svg && GalaxyChartManager.zoomBehavior)
|
||
GalaxyChartManager.svg
|
||
.transition()
|
||
.duration(500)
|
||
.call(GalaxyChartManager.zoomBehavior.transform, d3.zoomIdentity);
|
||
});
|
||
document
|
||
.getElementById("galaxyInfoClose")
|
||
.addEventListener("click", () => {
|
||
document
|
||
.getElementById("galaxyInfoPanel")
|
||
.classList.remove("visible");
|
||
});
|
||
document
|
||
.getElementById("galaxyCollapseAll")
|
||
.addEventListener("click", () => {
|
||
GalaxyChartManager.collapseAllProducts();
|
||
});
|
||
document
|
||
.getElementById("galaxyExpandAll")
|
||
.addEventListener("click", () => {
|
||
GalaxyChartManager.expandAllProducts();
|
||
});
|
||
document
|
||
.getElementById("galaxyExportPNG")
|
||
.addEventListener("click", () => {
|
||
showToast("📷 خروجی ذخیره شد", "success");
|
||
});
|
||
function handleToolSelect(tool, group) {
|
||
const ah = ActionRegistry[tool.action] || ActionRegistry.defaultAction;
|
||
ah(tool, group);
|
||
}
|
||
const themeToggle = document.getElementById("themeToggle"),
|
||
themeIcon = document.getElementById("themeIcon"),
|
||
htmlEl = document.documentElement;
|
||
const savedTheme = localStorage.getItem("theme") || "light";
|
||
htmlEl.setAttribute("data-theme", savedTheme);
|
||
if (themeIcon)
|
||
themeIcon.textContent = savedTheme === "dark" ? "☀️" : "🌙";
|
||
if (themeToggle) {
|
||
themeToggle.addEventListener("click", () => {
|
||
const c = htmlEl.getAttribute("data-theme");
|
||
const n = c === "dark" ? "light" : "dark";
|
||
htmlEl.setAttribute("data-theme", n);
|
||
localStorage.setItem("theme", n);
|
||
if (themeIcon) themeIcon.textContent = n === "dark" ? "☀️" : "🌙";
|
||
});
|
||
}
|
||
const sidebarToggle = document.getElementById("sidebarToggle"),
|
||
sidebar = document.getElementById("sidebar"),
|
||
sidebarIcon = document.getElementById("sidebarIcon");
|
||
function updateSidebarIcon() {
|
||
if (!sidebarIcon) return;
|
||
sidebarIcon.textContent = sidebar.classList.contains("collapsed")
|
||
? "☰"
|
||
: "✕";
|
||
}
|
||
if (sidebarToggle) {
|
||
sidebarToggle.addEventListener("click", () => {
|
||
sidebar.classList.toggle("collapsed");
|
||
updateSidebarIcon();
|
||
});
|
||
}
|
||
updateSidebarIcon();
|
||
const sidebarBody = document.getElementById("sidebarBody");
|
||
function renderToolGroups() {
|
||
sidebarBody.innerHTML = "";
|
||
projectData.toolGroups.forEach((group) => {
|
||
const ge = document.createElement("div");
|
||
ge.className = "sidebar-group";
|
||
const he = document.createElement("div");
|
||
he.className = "sidebar-group-header";
|
||
he.innerHTML = `<div class="group-icon"><span>${group.icon || ""}</span><span>${group.title}</span></div><span class="group-arrow">▼</span>`;
|
||
const be = document.createElement("div");
|
||
be.className = "sidebar-group-body";
|
||
group.tools.forEach((tool) => {
|
||
const btn = document.createElement("button");
|
||
btn.className = "sidebar-btn";
|
||
btn.title = tool.description;
|
||
btn.innerHTML = `<span style="width:20px;text-align:center">${tool.icon || "🔧"}</span><span style="flex:1">${tool.title}</span>`;
|
||
btn.addEventListener("click", () => handleToolSelect(tool, group));
|
||
be.appendChild(btn);
|
||
});
|
||
he.addEventListener("click", () => ge.classList.toggle("collapsed"));
|
||
ge.appendChild(he);
|
||
ge.appendChild(be);
|
||
sidebarBody.appendChild(ge);
|
||
});
|
||
}
|
||
function openSettingsModal() {
|
||
document.getElementById("settingProjectTitle").value =
|
||
projectData.projectTitle;
|
||
document.getElementById("settingVersion").value = projectData.version;
|
||
document.getElementById("settingDescription").value =
|
||
projectData.description;
|
||
document.getElementById("settingDeveloper").value =
|
||
projectData.developer;
|
||
document.getElementById("settingCompany").value = projectData.company;
|
||
document.getElementById("settingWebhookUrl").value =
|
||
projectData.webhook.url;
|
||
document.getElementById("settingWebhookClientId").value =
|
||
projectData.webhook.clientId;
|
||
document.getElementById("settingWebhookEnabled").checked =
|
||
projectData.webhook.enabled;
|
||
WebhookManager.updateStatusUI();
|
||
ModalManager.open("settingsModal");
|
||
}
|
||
[
|
||
"settingWebhookUrl",
|
||
"settingWebhookClientId",
|
||
"settingWebhookEnabled",
|
||
].forEach((id) => {
|
||
document.getElementById(id).addEventListener("input", () => {
|
||
WebhookManager.setConfig({
|
||
url: document.getElementById("settingWebhookUrl").value,
|
||
clientId: document.getElementById("settingWebhookClientId").value,
|
||
enabled: document.getElementById("settingWebhookEnabled").checked,
|
||
});
|
||
});
|
||
});
|
||
document
|
||
.getElementById("saveSettingsBtn")
|
||
.addEventListener("click", () => {
|
||
WebhookManager.setConfig({
|
||
url: document.getElementById("settingWebhookUrl").value.trim(),
|
||
clientId: document
|
||
.getElementById("settingWebhookClientId")
|
||
.value.trim(),
|
||
enabled: document.getElementById("settingWebhookEnabled").checked,
|
||
});
|
||
ModalManager.close("settingsModal");
|
||
showToast("تنظیمات ذخیره شد", "success");
|
||
});
|
||
function updateProjectUI() {
|
||
document.title = projectData.projectTitle;
|
||
document.getElementById("headerTitle").textContent =
|
||
projectData.projectTitle;
|
||
document.getElementById("headerSubtitle").textContent =
|
||
projectData.description;
|
||
document.getElementById("appFooter").innerHTML =
|
||
`<div><span class="footer-brand">طراح و توسعهدهنده:</span> ${projectData.developer} <span style="margin:0 10px">|</span><span class="footer-brand">شرکت:</span> ${projectData.company}</div><div><span class="footer-brand">نسخه:</span> ${projectData.version}</div>`;
|
||
}
|
||
/* ==================== INIT ==================== */
|
||
ModalManager.init();
|
||
renderToolGroups();
|
||
updateProjectUI();
|
||
WebhookManager.updateStatusUI();
|
||
ToolbarManager.init();
|
||
SectionCollapseManager.init();
|
||
BoardRenderer.render();
|
||
HistoryManager.init();
|
||
setTimeout(
|
||
() =>
|
||
showToast(
|
||
`${projectData.projectTitle} (نسخه ${projectData.version}) آماده است`,
|
||
"success",
|
||
),
|
||
500,
|
||
);
|
||
</script>
|
||
</body>
|
||
</html>
|