refactor: overhaul incident simulator component for enhanced user experience and functionality
- Replaced the existing HTML structure with a new layout focusing on a Cyber-Defense SOC Dashboard. - Streamlined the simulation configuration panel, including operation mode selection and target input. - Updated the simulation execution logic to utilize a more reactive approach with signals. - Removed deprecated logging and simulation stages, simplifying the overall codebase. - Enhanced error handling and loading states for better user feedback during simulations. - Introduced new metrics and forensic evidence display for improved simulation results.
This commit is contained in:
parent
7c2582b209
commit
3676637de3
|
|
@ -6,6 +6,8 @@ import { RetroScorePort } from './domain/ports/retro-score.port';
|
||||||
import { LocalStorageRetroScoreAdapter } from './infrastructure/adapters/local-storage-retro-score.adapter';
|
import { LocalStorageRetroScoreAdapter } from './infrastructure/adapters/local-storage-retro-score.adapter';
|
||||||
import { ChatPort } from './domain/ports/chat.port';
|
import { ChatPort } from './domain/ports/chat.port';
|
||||||
import { N8nChatAdapter } from './infrastructure/adapters/n8n-chat.adapter';
|
import { N8nChatAdapter } from './infrastructure/adapters/n8n-chat.adapter';
|
||||||
|
import { SecurityAgentPort } from './domain/ports/security-agent.port';
|
||||||
|
import { SecurityAgentService } from './services/security-agent.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
|
@ -13,6 +15,7 @@ export const appConfig: ApplicationConfig = {
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
{ provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter },
|
{ provide: RetroScorePort, useClass: LocalStorageRetroScoreAdapter },
|
||||||
{ provide: ChatPort, useClass: N8nChatAdapter }
|
{ provide: ChatPort, useClass: N8nChatAdapter },
|
||||||
|
{ provide: SecurityAgentPort, useClass: SecurityAgentService }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,179 @@
|
||||||
:host{display:block;width:100%}
|
:host {
|
||||||
.zen-sim-container{max-width:1540px;margin:0 auto;padding:0 24px 40px;display:flex;flex-direction:column;gap:16px;width:100%;box-sizing:border-box}
|
display: block;
|
||||||
.sim-section-header,.report-header,.console-titlebar,.arch-metrics-bar{display:flex;align-items:center;justify-content:space-between}
|
width: 100%;
|
||||||
.sim-section-header{width:100%;padding-bottom:8px;border-bottom:1px solid #1e2533;margin-bottom:4px;flex-wrap:wrap;gap:12px}
|
color: var(--zen-text-primary, #f0f3f8);
|
||||||
.header-left,.report-header-left,.email-input-wrapper,.story-step-card,.arch-node,.node-info,.secops-console-frame,.forensic-report-card,.report-metric-box,.metric-card{display:flex;flex-direction:column}
|
font-family: var(--font-sans, 'Plus Jakarta Sans', -apple-system, sans-serif);
|
||||||
.header-left{gap:4px}
|
}
|
||||||
.title-with-badge,.email-label,.action-buttons,.sim-trigger-btn,.mode-btn,.console-cursor-row,.download-report-btn{display:flex;align-items:center}
|
|
||||||
.title-with-badge{gap:12px}
|
.soc-dashboard {
|
||||||
.section-tag,.step-badge,.node-badge-tag,.report-badge,.box-label,.metric-label,.console-title,.console-pill,.log-row,.status-pill,.step-meta,.node-detail,.node-meta-ip,.node-meta-svc,.email-field,.box-val,.metric-val,.console-prompt{font-family:var(--font-mono,monospace)}
|
max-width: 1100px;
|
||||||
.section-tag{font-size:.6875rem;color:#5e697d;letter-spacing:.08em}
|
margin: 0 auto;
|
||||||
.pulse-indicator{width:10px;height:10px;border-radius:50%;background:#c59b27;box-shadow:0 0 12px #c59b27;flex-shrink:0}
|
padding: 1.5rem 1rem;
|
||||||
.section-title{margin:0;font-size:clamp(1.6rem,3vw,2.25rem);font-weight:800;color:#f0f3f8;line-height:1.15;text-transform:uppercase}
|
}
|
||||||
.header-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
||||||
.view-mode-toggle{display:inline-flex;background:#0d1117;border:1px solid #1e2533;border-radius:4px;padding:2px;gap:2px}
|
.soc-header {
|
||||||
.mode-btn{gap:6px;padding:5px 10px;background:0 0;border:none;border-radius:2px;color:#9aa5b8;font-size:.75rem;font-weight:600;cursor:pointer}
|
display: flex;
|
||||||
.mode-btn.active{background:#131720;color:#c59b27;box-shadow:0 1px 4px rgba(0,0,0,.4)}
|
justify-content: space-between;
|
||||||
.mode-icon{width:13px;height:13px}
|
align-items: center;
|
||||||
.status-pill{gap:8px;background:rgba(30,41,59,.6);border:1px solid rgba(255,255,255,.1);padding:6px 14px;border-radius:999px;font-size:.75rem;font-weight:600;color:#9aa5b8}
|
border-bottom: 1px solid var(--zen-border-subtle, #1e293b);
|
||||||
.status-beacon{width:8px;height:8px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;animation:pulseBeacon 2s infinite ease-in-out}
|
padding-bottom: 1.25rem;
|
||||||
.status-pill.danger .status-beacon,.dot.red{background:#ef4444}
|
margin-bottom: 1.5rem;
|
||||||
.status-pill.active .status-beacon,.dot.yellow{background:#eab308}
|
gap: 1rem;
|
||||||
.status-pill.contained .status-beacon,.dot.green{background:#22c55e}
|
flex-wrap: wrap;
|
||||||
.zen-sim-board{background:#0a0d14;border:1px solid #1e2533;border-radius:6px;padding:24px;display:flex;flex-direction:column;gap:20px}
|
}
|
||||||
.email-control-deck{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;background:#10141d;border:1px solid #1c2331;border-radius:4px;padding:14px 18px;flex-wrap:wrap}
|
|
||||||
.email-input-wrapper{gap:6px;flex:1;min-width:260px}
|
.logo-area { display: flex; align-items: center; gap: 0.85rem; }
|
||||||
.email-label{gap:6px;font-size:.78rem;font-weight:600;color:#9aa5b8}
|
.shield-badge {
|
||||||
.email-icon{width:14px;height:14px;color:#c59b27}
|
display: flex; align-items: center; justify-content: center;
|
||||||
.email-field{background:#07090e;border:1px solid #1e2533;border-radius:3px;color:#f0f3f8;font-size:.8125rem;padding:8px 12px;outline:0}
|
width: 44px; height: 44px;
|
||||||
.email-field:focus{border-color:#c59b27;box-shadow:0 0 6px rgba(197,155,39,.25)}
|
background: rgba(56, 189, 248, 0.12);
|
||||||
.action-buttons{gap:10px}
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
.sim-trigger-btn{gap:8px;background:#c59b27;color:#07080b;border:1px solid #e2b94a;border-radius:3px;padding:9px 18px;font-size:.84rem;font-weight:700;cursor:pointer}
|
border-radius: var(--zen-radius-sm, 4px);
|
||||||
.sim-trigger-btn:hover:not(:disabled){background:#d4af37;box-shadow:0 0 12px rgba(197,155,39,.4)}
|
color: #38bdf8;
|
||||||
.sim-trigger-btn:disabled{opacity:.6;cursor:not-allowed}
|
flex-shrink: 0;
|
||||||
.sim-reset-btn{background:0 0;color:#9aa5b8;border:1px solid #2d3748;border-radius:3px;padding:8px 14px;font-size:.8125rem;font-weight:600;cursor:pointer}
|
}
|
||||||
.story-grid-flow{display:flex;align-items:stretch;justify-content:space-between;gap:8px;width:100%}
|
.shield-icon { width: 22px; height: 22px; }
|
||||||
.story-step-card{flex:1;background:#11151f;border:1px solid #1e293b;border-radius:4px;padding:16px 14px;gap:8px;transition:all .3s}
|
.soc-title { font-size: 1.35rem; font-weight: 700; color: #38bdf8; margin: 0; }
|
||||||
.step-badge,.node-badge-tag{font-size:.625rem;font-weight:700;letter-spacing:.08em}
|
.soc-subtitle { font-size: 0.78rem; color: var(--zen-text-muted, #94a3b8); margin: 0.2rem 0 0; font-family: var(--font-mono, monospace); }
|
||||||
.step-badge{color:#64748b}
|
.engine-badge {
|
||||||
.step-icon-wrap{width:36px;height:36px;border-radius:4px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);color:#9aa5b8}
|
background: #111827; color: #94a3b8; padding: 0.35rem 0.75rem;
|
||||||
.step-icon-wrap svg{width:20px;height:20px}
|
border-radius: var(--zen-radius-xs, 4px); font-size: 0.78rem; font-family: var(--font-mono, monospace);
|
||||||
.node-badge-tag.threat,.step-icon-wrap.danger{color:#f87171;background:rgba(239,68,68,.1)}
|
border: 1px solid #1e293b;
|
||||||
.node-badge-tag.detection,.step-icon-wrap.warning{color:#38bdf8;background:rgba(56,189,248,.1)}
|
}
|
||||||
.node-badge-tag.remediation,.step-icon-wrap.action{color:#4ade80;background:rgba(34,197,94,.1)}
|
|
||||||
.node-badge-tag.notification,.step-icon-wrap.success{color:#e2b94a;background:rgba(197,155,39,.1)}
|
.control-panel, .results-dashboard {
|
||||||
.step-title,.node-title{font-size:.8125rem;font-weight:700;color:#f0f3f8;margin:0}
|
background: #111827; border: 1px solid #1f2937;
|
||||||
.step-title{font-size:.875rem}
|
border-radius: var(--zen-radius-sm, 6px); padding: 1.5rem;
|
||||||
.step-desc{font-size:.75rem;color:#9aa5b8;line-height:1.45;margin:0;flex-grow:1}
|
margin-bottom: 1.5rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
.step-meta{font-size:.6875rem;color:#64748b;border-top:1px solid #1c2331;padding-top:6px}
|
}
|
||||||
.story-step-card.active{border-color:#c59b27;background:#171b26;box-shadow:0 0 16px rgba(197,155,39,.2);transform:translateY(-2px)}
|
|
||||||
.story-step-card.completed,.arch-node.past{border-color:rgba(34,197,94,.4)}
|
.panel-tag { display: inline-block; font-family: var(--font-mono, monospace); font-size: 0.72rem; color: #e2b94a; margin-bottom: 0.3rem; }
|
||||||
.story-arrow{display:flex;align-items:center;justify-content:center;color:#2d3748;font-size:1.1rem;font-weight:700;padding:0 4px}
|
.panel-title { font-size: 1.15rem; font-weight: 600; color: #f8fafc; margin: 0 0 1.25rem; }
|
||||||
.story-arrow.active{color:#c59b27}
|
|
||||||
.diagram-canvas{display:grid;grid-template-columns:1fr auto 1fr auto 1fr auto 1fr;align-items:center;gap:8px;width:100%}
|
.form-grid {
|
||||||
.arch-node{background:#11151f;border:1px solid #1e293b;border-radius:4px;padding:12px 10px;gap:6px;position:relative;min-height:125px;box-sizing:border-box}
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
.node-badge-tag{padding:2px 6px;border-radius:2px;width:fit-content}
|
gap: 1.25rem; margin-bottom: 1.25rem;
|
||||||
.node-icon-frame{width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#9aa5b8;flex-shrink:0}
|
}
|
||||||
.node-svg{width:22px;height:22px;max-width:22px;max-height:22px;display:block}
|
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
|
||||||
.node-info{gap:2px}
|
.form-group input[type="text"], .form-group input[type="email"], .form-group select {
|
||||||
.node-detail,.node-meta-ip,.node-meta-svc{font-size:.6875rem;color:#9aa5b8}
|
width: 100%; padding: 0.7rem 0.85rem; background: #0f172a; border: 1px solid #334155;
|
||||||
.node-meta-ip,.node-meta-svc{font-size:.625rem;color:#64748b}
|
color: #f8fafc; border-radius: var(--zen-radius-xs, 4px); box-sizing: border-box;
|
||||||
.node-status-indicator{position:absolute;top:8px;right:8px;width:6px;height:6px;border-radius:50%;background:#334155}
|
font-family: var(--font-mono, monospace); font-size: 0.88rem; transition: border-color 0.15s;
|
||||||
.threat-node.active{border-color:#ef4444;background:#1c1316}
|
}
|
||||||
.threat-node.active .node-icon-frame,.threat-node.active .node-status-indicator.ping{color:#ef4444;background:#ef4444}
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: #38bdf8; }
|
||||||
.detection-node.active{border-color:#38bdf8;background:#0f1c29}
|
|
||||||
.detection-node.active .node-icon-frame,.detection-node.active .node-status-indicator.ping{color:#38bdf8;background:#38bdf8}
|
.target-presets { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; flex-wrap: wrap; }
|
||||||
.remediation-node.active{border-color:#22c55e;background:#0f2316}
|
.preset-label { font-size: 0.72rem; color: #64748b; font-family: var(--font-mono, monospace); }
|
||||||
.remediation-node.active .node-icon-frame,.remediation-node.active .node-status-indicator.ping{color:#22c55e;background:#22c55e}
|
.preset-btn {
|
||||||
.notification-node.active{border-color:#c59b27;background:#231c0e}
|
background: #1e293b; border: 1px solid #334155; color: #93c5fd; font-size: 0.7rem;
|
||||||
.notification-node.active .node-icon-frame,.notification-node.active .node-status-indicator.ping{color:#c59b27;background:#c59b27}
|
font-family: var(--font-mono, monospace); padding: 0.15rem 0.45rem; border-radius: 3px; cursor: pointer;
|
||||||
.arch-node.past .node-status-indicator{background:#22c55e}
|
}
|
||||||
.flow-connector{display:flex;align-items:center;justify-content:center;position:relative;height:20px;color:#334155}
|
.preset-btn:hover { background: #334155; color: #ffffff; }
|
||||||
.beam-line{position:absolute;top:50%;left:0;right:0;height:2px;background:#1e293b;transform:translateY(-50%)}
|
|
||||||
.flow-arrow{position:relative;z-index:2;font-size:.625rem;background:#0a0d14;padding:0 2px}
|
.checkbox-group { display: flex; align-items: center; }
|
||||||
.flow-connector.flowing{color:#38bdf8}
|
.checkbox-label { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-size: 0.85rem; color: #cbd5e1; }
|
||||||
.flow-connector.flowing .beam-line{background:linear-gradient(90deg,#ef4444,#38bdf8,#22c55e)}
|
.checkbox-label input[type="checkbox"] { width: 18px; height: 18px; accent-color: #0284c7; cursor: pointer; }
|
||||||
.secops-console-frame{background:#06080c;border:1px solid #1e2533;border-radius:4px;overflow:hidden}
|
|
||||||
.console-titlebar{background:#0f131a;padding:6px 10px;border-bottom:1px solid #1e2533}
|
.launch-btn {
|
||||||
.console-dots{display:flex;gap:5px}
|
width: 100%; padding: 0.85rem 1.25rem;
|
||||||
.dot{width:7px;height:7px;border-radius:50%}
|
background: linear-gradient(135deg, #0284c7 0%, #2563eb 100%);
|
||||||
.console-title{font-size:.6875rem;color:#94a3b8}
|
color: #ffffff; border: none; border-radius: var(--zen-radius-sm, 6px);
|
||||||
.console-pill{font-size:.5625rem;color:#38bdf8;background:rgba(56,189,248,.12);padding:2px 5px;border-radius:2px}
|
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||||
.console-body{padding:8px 10px;min-height:100px;max-height:160px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;font-size:.75rem;line-height:1.4}
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
.log-row{display:flex;gap:8px;color:#94a3b8}
|
transition: opacity 0.2s;
|
||||||
.log-time{color:#475569;flex-shrink:0}
|
}
|
||||||
.log-tag{font-weight:700;flex-shrink:0}
|
.launch-btn:hover:not(:disabled) { opacity: 0.92; }
|
||||||
.log-row.warn .log-msg,.log-row.warn .log-tag{color:#f87171}
|
.launch-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.log-row.action .log-msg,.log-row.action .log-tag{color:#fde047}
|
.btn-icon { width: 18px; height: 18px; }
|
||||||
.log-row.success .log-msg,.log-row.success .log-tag{color:#4ade80}
|
.spinner-dot {
|
||||||
.console-cursor-row{gap:6px;margin-top:3px}
|
width: 10px; height: 10px; border-radius: 50%; background: #38bdf8;
|
||||||
.console-prompt{color:#34d399;font-size:.6875rem}
|
animation: pulse-spin 1s infinite alternate;
|
||||||
.cursor-block{width:6px;height:11px;background:#38bdf8}
|
}
|
||||||
.forensic-report-card{background:linear-gradient(180deg,#10151f 0%,#0c1017 100%);border:1px solid rgba(197,155,39,.4);border-radius:6px;padding:20px;gap:16px;box-shadow:0 4px 20px rgba(0,0,0,.4);animation:fadeInReport .4s ease-out}
|
@keyframes pulse-spin { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.3); opacity: 1; } }
|
||||||
@keyframes fadeInReport{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
||||||
.report-header{flex-wrap:wrap;gap:12px;border-bottom:1px solid #1e293b;padding-bottom:12px}
|
.alert-box.error {
|
||||||
.report-header-left{gap:4px}
|
background: #450a0a; color: #fca5a5; border: 1px solid #7f1d1d;
|
||||||
.report-badge{font-size:.625rem;color:#c59b27;letter-spacing:.1em;font-weight:700}
|
padding: 1rem; border-radius: var(--zen-radius-sm, 4px); margin-bottom: 1.5rem;
|
||||||
.report-title{margin:0;font-size:1.05rem;font-weight:800;color:#f0f3f8}
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
.report-status-pill{background:rgba(34,197,94,.15);border:1px solid rgba(34,197,94,.4);color:#4ade80;font-size:.6875rem;font-weight:700;padding:4px 10px;border-radius:999px}
|
}
|
||||||
.report-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
.alert-icon { width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
.report-metric-box{background:#080b10;border:1px solid #1a2230;border-radius:4px;padding:10px 12px;gap:4px}
|
|
||||||
.box-label{font-size:.625rem;color:#64748b;text-transform:uppercase}
|
.results-header {
|
||||||
.box-val{font-size:.8125rem;font-weight:700;color:#f0f3f8}
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
.box-val.highlight-mail{color:#e2b94a}
|
border-bottom: 1px solid #1e293b; padding-bottom: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem;
|
||||||
.box-val.highlight-time{color:#38bdf8}
|
}
|
||||||
.box-val.success{color:#4ade80}
|
.results-title-group { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
.report-footer{display:flex;justify-content:flex-end}
|
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; }
|
||||||
.download-report-btn{gap:8px;background:0 0;border:1px solid rgba(197,155,39,.5);color:#c59b27;padding:8px 16px;border-radius:3px;font-size:.8125rem;font-weight:600;cursor:pointer}
|
.results-header h2 { font-size: 1.15rem; font-weight: 600; color: #f8fafc; margin: 0; }
|
||||||
.download-report-btn:hover{background:rgba(197,155,39,.1);border-color:#c59b27}
|
.header-badges { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
.dl-icon{width:15px;height:15px}
|
.status-pill.status-completed {
|
||||||
.arch-metrics-bar{padding:16px 12px 6px;border-top:1px solid #1a2230;flex-wrap:nowrap;gap:16px;width:100%;box-sizing:border-box}
|
background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
.metric-card{align-items:center;justify-content:center;gap:4px;text-align:center;flex:1}
|
padding: 0.3rem 0.65rem; border-radius: var(--zen-radius-xs, 4px); font-size: 0.75rem;
|
||||||
.metric-label{font-size:.6875rem;color:#5e697d;text-transform:uppercase;letter-spacing:.04em}
|
font-family: var(--font-mono, monospace); font-weight: 600;
|
||||||
.metric-val{font-size:.875rem;font-weight:700;color:#f0f3f8}
|
}
|
||||||
.metric-val.highlight{color:#38bdf8}
|
.reset-btn {
|
||||||
.metric-val.success{color:#4ade80}
|
background: #1e293b; color: #94a3b8; border: 1px solid #334155; padding: 0.3rem 0.65rem;
|
||||||
.metric-divider{width:1px;height:28px;background:#1e293b;flex-shrink:0}
|
border-radius: var(--zen-radius-xs, 4px); font-size: 0.75rem; cursor: pointer;
|
||||||
@keyframes pulseBeacon{0%,100%{opacity:.6;transform:scale(1)}50%{opacity:1;transform:scale(1.3)}}
|
}
|
||||||
@media (max-width:1024px){
|
.reset-btn:hover { background: #334155; color: #f8fafc; }
|
||||||
.story-grid-flow{flex-direction:column}
|
|
||||||
.story-arrow{transform:rotate(90deg);margin:-4px 0}
|
.metrics-grid {
|
||||||
.report-grid{grid-template-columns:repeat(2,1fr)}
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
.diagram-canvas{grid-template-columns:1fr;gap:10px}
|
gap: 1rem; margin-bottom: 1.5rem;
|
||||||
.flow-connector{height:16px;width:100%;transform:rotate(90deg)}
|
}
|
||||||
.metric-divider{display:none}
|
.metric-card {
|
||||||
.arch-metrics-bar{flex-direction:column;gap:12px}
|
background: #1e293b; border-radius: var(--zen-radius-xs, 4px); padding: 1.1rem;
|
||||||
}
|
text-align: center; border: 1px solid #2d3748;
|
||||||
|
}
|
||||||
|
.metric-title { display: block; font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.5rem; text-transform: uppercase; }
|
||||||
|
.verdict-tag {
|
||||||
|
display: inline-block; padding: 0.35rem 0.85rem; border-radius: var(--zen-radius-xs, 4px);
|
||||||
|
font-weight: 700; font-size: 0.85rem; font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.verdict-tag.critical_threat { background: #991b1b; color: #fee2e2; border: 1px solid #b91c1c; }
|
||||||
|
.verdict-tag.attention_required { background: #854d0e; color: #fef08a; border: 1px solid #a16207; }
|
||||||
|
.verdict-tag.safe { background: #166534; color: #dcfce7; border: 1px solid #15803d; }
|
||||||
|
|
||||||
|
.risk-gauge { font-size: 1.6rem; font-weight: 800; font-family: var(--font-mono, monospace); }
|
||||||
|
.risk-gauge.high-risk { color: #f87171; }
|
||||||
|
.risk-gauge.med-risk { color: #facc15; }
|
||||||
|
.risk-gauge.safe-risk { color: #4ade80; }
|
||||||
|
.gauge-max { font-size: 0.9rem; color: #64748b; font-weight: 400; }
|
||||||
|
.metric-value.highlight { font-size: 1.6rem; font-weight: 700; color: #38bdf8; font-family: var(--font-mono, monospace); }
|
||||||
|
.containment-value { font-size: 0.88rem; font-weight: 600; color: #94a3b8; }
|
||||||
|
.containment-value.contained { color: #4ade80; }
|
||||||
|
|
||||||
|
.mitre-section { margin-bottom: 1.5rem; }
|
||||||
|
.mitre-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.6rem; }
|
||||||
|
.mitre-label { font-size: 0.85rem; color: #cbd5e1; font-weight: 600; }
|
||||||
|
.mitre-count { font-size: 0.75rem; color: #94a3b8; font-family: var(--font-mono, monospace); }
|
||||||
|
.mitre-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.chip {
|
||||||
|
background: #1e293b; border: 1px solid #334155; color: #cbd5e1;
|
||||||
|
padding: 0.35rem 0.75rem; border-radius: var(--zen-radius-xs, 4px);
|
||||||
|
font-family: var(--font-mono, monospace); font-size: 0.82rem; display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: #38bdf8; }
|
||||||
|
|
||||||
|
.forensic-box {
|
||||||
|
background: #090d16; border: 1px dashed #38bdf8; border-radius: var(--zen-radius-xs, 4px);
|
||||||
|
padding: 1.25rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.forensic-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.85rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.forensic-title { font-size: 0.88rem; font-weight: 600; color: #38bdf8; }
|
||||||
|
.timestamp-tag { font-size: 0.72rem; color: #64748b; font-family: var(--font-mono, monospace); }
|
||||||
|
.forensic-body p { margin: 0.4rem 0; font-size: 0.82rem; color: #cbd5e1; }
|
||||||
|
.forensic-body code {
|
||||||
|
font-family: var(--font-mono, monospace); font-size: 0.82rem;
|
||||||
|
background: rgba(15, 23, 42, 0.8); padding: 0.15rem 0.4rem; border-radius: 3px; color: #93c5fd;
|
||||||
|
}
|
||||||
|
.hash-code { color: #4ade80 !important; word-break: break-all; }
|
||||||
|
.action-buttons { margin-top: 1rem; }
|
||||||
|
.report-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.65rem 1.25rem;
|
||||||
|
background: #0284c7; color: #ffffff; text-decoration: none; border-radius: var(--zen-radius-xs, 4px);
|
||||||
|
font-size: 0.85rem; font-weight: 500; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.report-btn:hover { background: #0369a1; }
|
||||||
|
.report-icon { width: 16px; height: 16px; }
|
||||||
|
.external-arrow { font-size: 0.95rem; }
|
||||||
|
|
@ -1,367 +1,222 @@
|
||||||
<section class="zen-sim-container" aria-label="Simulador de Respuesta a Incidentes Cloud">
|
<div class="soc-dashboard" aria-label="Autonomous Cyber-Defense SOC Dashboard">
|
||||||
<!-- Cabecera de Sección con Selector de Modo y Status -->
|
<!-- Encabezado Táctico del SOC -->
|
||||||
<header class="sim-section-header">
|
<header class="soc-header">
|
||||||
<div class="header-left">
|
<div class="logo-area">
|
||||||
<div class="title-with-badge">
|
<div class="shield-badge">
|
||||||
<span class="pulse-indicator" aria-hidden="true"></span>
|
<svg class="shield-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<h2 class="section-title">{{ i18n.t().navSimulator }}</h2>
|
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
|
||||||
|
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="section-tag">{{ i18n.t().simTag }}</span>
|
<div>
|
||||||
</div>
|
<h1 class="soc-title">Autonomous Cyber-Defense & Purple Teaming</h1>
|
||||||
|
<p class="soc-subtitle">Triage Cognitivo DeepSeek // Orquestador n8n // Evidencia Criptográfica Gotenberg</p>
|
||||||
<div class="header-controls">
|
|
||||||
<!-- Selector de Modo de Vista (Humana vs Técnica) -->
|
|
||||||
<div class="view-mode-toggle" role="group" aria-label="Modo de visualización">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mode-btn"
|
|
||||||
[class.active]="viewMode() === 'story'"
|
|
||||||
(click)="setViewMode('story')"
|
|
||||||
>
|
|
||||||
<svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ i18n.t().simModeStory }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mode-btn"
|
|
||||||
[class.active]="viewMode() === 'tech'"
|
|
||||||
(click)="setViewMode('tech')"
|
|
||||||
>
|
|
||||||
<svg class="mode-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="4 17 10 11 4 5"/>
|
|
||||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ i18n.t().simModeTech }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Pill -->
|
|
||||||
<div class="status-pill" [class.danger]="stage() === 'breach' || stage() === 'detection'" [class.contained]="isContained()" [class.active]="stage() === 'remediation' || stage() === 'notification'">
|
|
||||||
<span class="status-beacon"></span>
|
|
||||||
<span class="status-text">
|
|
||||||
@if (stage() === 'idle') {
|
|
||||||
{{ i18n.t().simStatusIdle }}
|
|
||||||
} @else if (isContained()) {
|
|
||||||
{{ i18n.t().simStatusContained }}
|
|
||||||
} @else {
|
|
||||||
{{ i18n.t().simStatusActive }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="engine-badge">Engine: n8n + DeepSeek + Gotenberg</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Panel Principal del Simulador -->
|
<!-- Panel de Configuración de la Simulación -->
|
||||||
<div class="zen-sim-board">
|
<section class="control-panel">
|
||||||
<!-- Barra Interactiva de Captura de Correo y Ejecución -->
|
<div class="panel-header">
|
||||||
<div class="email-control-deck">
|
<span class="panel-tag">SECOPS // OPERATIONS DISPATCHER</span>
|
||||||
<div class="email-input-wrapper">
|
<h2 class="panel-title">Lanzador de Operaciones de Ciberdefensa</h2>
|
||||||
<label for="sim-email-input" class="email-label">
|
</div>
|
||||||
<svg class="email-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
<div class="form-grid">
|
||||||
<polyline points="22,6 12,13 2,6"/>
|
<!-- Modo de Operación -->
|
||||||
</svg>
|
<div class="form-group">
|
||||||
<span>{{ i18n.t().simEmailLabel }}</span>
|
<label for="operation-mode">Modo de Operación:</label>
|
||||||
</label>
|
<select
|
||||||
|
id="operation-mode"
|
||||||
|
[ngModel]="mode()"
|
||||||
|
(ngModelChange)="mode.set($event)"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
>
|
||||||
|
<option value="PURPLE_TEAM">PURPLE TEAM (Adversary Emulation + Defense)</option>
|
||||||
|
<option value="EASM">EASM (Superficie de Ataque Externa)</option>
|
||||||
|
<option value="SOAR">SOAR (Enriquecimiento e Inteligencia)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Objetivo -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="operation-target">Objetivo (Target / FQDN / IP):</label>
|
||||||
<input
|
<input
|
||||||
id="sim-email-input"
|
id="operation-target"
|
||||||
|
type="text"
|
||||||
|
[ngModel]="target()"
|
||||||
|
(ngModelChange)="target.set($event)"
|
||||||
|
placeholder="local_sandbox o IP/Dominio"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
/>
|
||||||
|
<div class="target-presets">
|
||||||
|
<span class="preset-label">Presets:</span>
|
||||||
|
<button type="button" class="preset-btn" (click)="setTargetPreset('local_sandbox')">local_sandbox</button>
|
||||||
|
<button type="button" class="preset-btn" (click)="setTargetPreset('gateway.perimeter.internal')">gateway.perimeter</button>
|
||||||
|
<button type="button" class="preset-btn" (click)="setTargetPreset('api.bryan-bano.com')">api.bryan-bano.com</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auditor / Solicitante -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="operation-auditor">Auditor / Solicitante:</label>
|
||||||
|
<input
|
||||||
|
id="operation-auditor"
|
||||||
type="email"
|
type="email"
|
||||||
class="email-field"
|
[ngModel]="requestedBy()"
|
||||||
[value]="userEmail()"
|
(ngModelChange)="requestedBy.set($event)"
|
||||||
(input)="onEmailInput($event)"
|
placeholder="auditor@empresa.com"
|
||||||
[placeholder]="i18n.t().simEmailPlaceholder"
|
[disabled]="isLoading()"
|
||||||
[disabled]="isSimulating()"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<!-- Aislamiento Zero-Trust -->
|
||||||
<button
|
<div class="form-group checkbox-group">
|
||||||
type="button"
|
<label class="checkbox-label" for="operation-isolate">
|
||||||
class="sim-trigger-btn"
|
<input
|
||||||
[disabled]="isSimulating()"
|
id="operation-isolate"
|
||||||
(click)="startSimulation()"
|
type="checkbox"
|
||||||
[attr.aria-label]="i18n.t().simBtnSimulate"
|
[ngModel]="autoIsolate()"
|
||||||
>
|
(ngModelChange)="autoIsolate.set($event)"
|
||||||
<span class="btn-glow"></span>
|
[disabled]="isLoading()"
|
||||||
<span class="btn-icon">⚡</span>
|
/>
|
||||||
<span class="btn-text">
|
<span class="checkbox-text">Aislamiento y Contención Automática (Zero-Trust)</span>
|
||||||
@if (isSimulating()) {
|
</label>
|
||||||
{{ i18n.t().simBtnSimulating }}
|
|
||||||
} @else {
|
|
||||||
{{ i18n.t().simBtnSimulate }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="sim-reset-btn"
|
|
||||||
[disabled]="isSimulating()"
|
|
||||||
(click)="resetSimulation()"
|
|
||||||
[attr.aria-label]="i18n.t().simBtnReset"
|
|
||||||
>
|
|
||||||
<span>↺ {{ i18n.t().simBtnReset }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VISTA 1: MODO STORY / EXPLICADO PARA TODO PÚBLICO -->
|
<!-- Botón de Lanzamiento -->
|
||||||
@if (viewMode() === 'story') {
|
<button
|
||||||
<div class="story-grid-flow" aria-live="polite">
|
type="button"
|
||||||
<!-- PASO 1 -->
|
class="launch-btn"
|
||||||
<div class="story-step-card" [class.active]="stage() === 'breach'" [class.completed]="stage() !== 'idle' && stage() !== 'breach'">
|
(click)="executeSimulation()"
|
||||||
<div class="step-badge">01 // ATAQUE</div>
|
[disabled]="isLoading()"
|
||||||
<div class="step-icon-wrap danger">
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
@if (!isLoading()) {
|
||||||
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
|
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span>Iniciar Simulación de Ciberdefensa</span>
|
||||||
|
} @else {
|
||||||
|
<span class="spinner-dot"></span>
|
||||||
|
<span>Ejecutando Ciclo Cerrado (Triage Cognitivo DeepSeek)...</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Estado de Alerta de Error -->
|
||||||
|
@if (errorMessage(); as error) {
|
||||||
|
<div class="alert-box error" role="alert">
|
||||||
|
<svg class="alert-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<div class="alert-content">
|
||||||
|
<strong>Error en Operación:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Dashboard de Resultados en Tiempo Real -->
|
||||||
|
@if (result(); as res) {
|
||||||
|
<section class="results-dashboard" aria-live="polite">
|
||||||
|
<div class="results-header">
|
||||||
|
<div class="results-title-group">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<h2>Telemetría Forense Consolidada</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-badges">
|
||||||
|
<span class="status-pill status-completed">COMPLETADO (200 OK)</span>
|
||||||
|
<button type="button" class="reset-btn" (click)="resetSimulation()" title="Nueva Simulación">
|
||||||
|
Nueva Consulta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<!-- Veredicto Cognitivo -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-title">Veredicto DeepSeek</span>
|
||||||
|
<div class="verdict-tag" [ngClass]="res.summary.verdict.toLowerCase()">
|
||||||
|
{{ res.summary.verdict }}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="step-title">{{ i18n.t().simStep1Title }}</h3>
|
|
||||||
<p class="step-desc">{{ i18n.t().simStep1Desc }}</p>
|
|
||||||
<div class="step-meta">IP: {{ threatIp() }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-arrow" [class.active]="stage() !== 'idle'">→</div>
|
<!-- Risk Score -->
|
||||||
|
<div class="metric-card">
|
||||||
<!-- PASO 2 -->
|
<span class="metric-title">Risk Score</span>
|
||||||
<div class="story-step-card" [class.active]="stage() === 'detection'" [class.completed]="stage() === 'remediation' || stage() === 'notification' || isContained()">
|
<div
|
||||||
<div class="step-badge">02 // DETECCIÓN</div>
|
class="risk-gauge"
|
||||||
<div class="step-icon-wrap warning">
|
[ngClass]="res.summary.risk_score > 70 ? 'high-risk' : (res.summary.risk_score > 30 ? 'med-risk' : 'safe-risk')"
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
>
|
||||||
<circle cx="12" cy="12" r="10"/>
|
{{ res.summary.risk_score }} <span class="gauge-max">/ 100</span>
|
||||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="step-title">{{ i18n.t().simStep2Title }}</h3>
|
|
||||||
<p class="step-desc">{{ i18n.t().simStep2Desc }}</p>
|
|
||||||
<div class="step-meta">< 50ms // Monitoreo Activo</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-arrow" [class.active]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">→</div>
|
<!-- Latencia de Mitigación -->
|
||||||
|
<div class="metric-card">
|
||||||
<!-- PASO 3 -->
|
<span class="metric-title">Latencia de Mitigación (Δt)</span>
|
||||||
<div class="story-step-card" [class.active]="stage() === 'remediation'" [class.completed]="stage() === 'notification' || isContained()">
|
<div class="metric-value highlight">{{ res.summary.mitigation_time_ms }} ms</div>
|
||||||
<div class="step-badge">03 // BLOQUEO</div>
|
|
||||||
<div class="step-icon-wrap action">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="step-title">{{ i18n.t().simStep3Title }}</h3>
|
|
||||||
<p class="step-desc">{{ i18n.t().simStep3Desc }}</p>
|
|
||||||
<div class="step-meta">Revocación Inmediata STS</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-arrow" [class.active]="stage() === 'remediation' || stage() === 'notification' || isContained()">→</div>
|
<!-- Contención Docker / Red -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-title">Contención de Red</span>
|
||||||
|
<div class="metric-value containment-value" [class.contained]="res.summary.containment_executed">
|
||||||
|
{{ res.summary.containment_executed ? 'Aislado (Host Bridge Detached)' : 'Monitoreo Activo' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PASO 4 -->
|
<!-- Técnicas MITRE ATT&CK -->
|
||||||
<div class="story-step-card" [class.active]="stage() === 'notification'" [class.completed]="isContained()">
|
<div class="mitre-section">
|
||||||
<div class="step-badge">04 // INFORME</div>
|
<div class="mitre-header">
|
||||||
<div class="step-icon-wrap success">
|
<span class="mitre-label">Técnicas MITRE ATT&CK v14 Correlacionadas</span>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<span class="mitre-count">{{ res.summary.mitre_techniques.length }} detectadas</span>
|
||||||
|
</div>
|
||||||
|
<div class="mitre-chips">
|
||||||
|
@for (tech of res.summary.mitre_techniques; track tech) {
|
||||||
|
<span class="chip">
|
||||||
|
<span class="chip-dot"></span>
|
||||||
|
{{ tech }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evidencia Forense & Hash Criptográfico -->
|
||||||
|
<div class="forensic-box">
|
||||||
|
<div class="forensic-header">
|
||||||
|
<span class="forensic-title">Evidencia Forense Criptográfica (Principio de No Repudio)</span>
|
||||||
|
<span class="timestamp-tag">{{ res.timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="forensic-body">
|
||||||
|
<p><strong>Simulation UUID:</strong> <code>{{ res.simulation_id }}</code></p>
|
||||||
|
<p><strong>Execution ID:</strong> <code>{{ res.execution_id }}</code></p>
|
||||||
|
<p><strong>Firma SHA-256:</strong> <code class="hash-code">{{ res.evidence.sha256_hash }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a
|
||||||
|
[href]="res.evidence.report_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="report-btn"
|
||||||
|
>
|
||||||
|
<svg class="report-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<span>Ver Reporte Forense en Navegador</span>
|
||||||
<h3 class="step-title">{{ i18n.t().simStep4Title }}</h3>
|
<span class="external-arrow">↗</span>
|
||||||
<p class="step-desc">{{ i18n.t().simStep4Desc }}</p>
|
</a>
|
||||||
<div class="step-meta">{{ targetRecipient() }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</section>
|
||||||
|
}
|
||||||
<!-- VISTA 2: MODO TÉCNICO SECOPS / AWS ARQUITECTURA -->
|
</div>
|
||||||
@if (viewMode() === 'tech') {
|
|
||||||
<div class="diagram-canvas">
|
|
||||||
<!-- NODO 1: Vector de Amenaza -->
|
|
||||||
<div class="arch-node threat-node" [class.active]="stage() === 'breach'" [class.past]="stage() !== 'idle' && stage() !== 'breach'">
|
|
||||||
<div class="node-badge-tag threat">VECTOR // 01</div>
|
|
||||||
<div class="node-icon-frame">
|
|
||||||
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
|
|
||||||
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="node-info">
|
|
||||||
<h3 class="node-title">{{ i18n.t().simThreatNodeTitle }}</h3>
|
|
||||||
<span class="node-detail">{{ i18n.t().simThreatNodeSub }}</span>
|
|
||||||
<span class="node-meta-ip">IP: {{ threatIp() }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="node-status-indicator" [class.ping]="stage() === 'breach'"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONECTOR 1 -> 2 -->
|
|
||||||
<div class="flow-connector" [class.flowing]="stage() !== 'idle'">
|
|
||||||
<div class="beam-line"></div>
|
|
||||||
<span class="flow-arrow">►</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NODO 2: AWS CloudTrail & EventBridge -->
|
|
||||||
<div class="arch-node detection-node" [class.active]="stage() === 'detection'" [class.past]="stage() === 'remediation' || stage() === 'notification' || isContained()">
|
|
||||||
<div class="node-badge-tag detection">AWS // 02</div>
|
|
||||||
<div class="node-icon-frame">
|
|
||||||
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="node-info">
|
|
||||||
<h3 class="node-title">{{ i18n.t().simCloudTrailNodeTitle }}</h3>
|
|
||||||
<span class="node-detail">{{ i18n.t().simCloudTrailNodeSub }}</span>
|
|
||||||
<span class="node-meta-svc">us-east-1 // EventBridge Rule</span>
|
|
||||||
</div>
|
|
||||||
<div class="node-status-indicator" [class.ping]="stage() === 'detection'"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONECTOR 2 -> 3 -->
|
|
||||||
<div class="flow-connector" [class.flowing]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
|
|
||||||
<div class="beam-line"></div>
|
|
||||||
<span class="flow-arrow">►</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NODO 3: AWS Lambda -->
|
|
||||||
<div class="arch-node remediation-node" [class.active]="stage() === 'remediation'" [class.past]="stage() === 'notification' || isContained()">
|
|
||||||
<div class="node-badge-tag remediation">SERVERLESS // 03</div>
|
|
||||||
<div class="node-icon-frame">
|
|
||||||
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="node-info">
|
|
||||||
<h3 class="node-title">{{ i18n.t().simLambdaNodeTitle }}</h3>
|
|
||||||
<span class="node-detail">{{ i18n.t().simLambdaNodeSub }}</span>
|
|
||||||
<span class="node-meta-svc">STS Invalidation & DenyPolicy</span>
|
|
||||||
</div>
|
|
||||||
<div class="node-status-indicator" [class.ping]="stage() === 'remediation'"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONECTOR 3 -> 4 -->
|
|
||||||
<div class="flow-connector" [class.flowing]="stage() === 'remediation' || stage() === 'notification' || isContained()">
|
|
||||||
<div class="beam-line"></div>
|
|
||||||
<span class="flow-arrow">►</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NODO 4: Amazon SES -->
|
|
||||||
<div class="arch-node notification-node" [class.active]="stage() === 'notification'" [class.past]="isContained()">
|
|
||||||
<div class="node-badge-tag notification">SECOPS // 04</div>
|
|
||||||
<div class="node-icon-frame">
|
|
||||||
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
||||||
<polyline points="22,6 12,13 2,6"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="node-info">
|
|
||||||
<h3 class="node-title">{{ i18n.t().simSesNodeTitle }}</h3>
|
|
||||||
<span class="node-detail">{{ i18n.t().simSesNodeSub }}</span>
|
|
||||||
<span class="node-meta-svc">{{ targetRecipient() }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="node-status-indicator" [class.ping]="stage() === 'notification'"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Consola de Telemetría SecOps -->
|
|
||||||
<div class="secops-console-frame">
|
|
||||||
<header class="console-titlebar">
|
|
||||||
<div class="console-dots">
|
|
||||||
<span class="dot red"></span>
|
|
||||||
<span class="dot yellow"></span>
|
|
||||||
<span class="dot green"></span>
|
|
||||||
</div>
|
|
||||||
<span class="console-title">{{ i18n.t().simConsoleTitle }}</span>
|
|
||||||
<span class="console-pill">JSON LOGSTREAM</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="console-body" role="log" aria-live="polite">
|
|
||||||
@for (log of logs(); track $index) {
|
|
||||||
<div class="log-row" [class.warn]="log.level === 'WARN'" [class.action]="log.level === 'ACTION'" [class.success]="log.level === 'SUCCESS'">
|
|
||||||
<span class="log-time">[{{ log.timestamp }}]</span>
|
|
||||||
<span class="log-tag">[{{ log.tag }}]</span>
|
|
||||||
<span class="log-msg">{{ log.message }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="console-cursor-row">
|
|
||||||
<span class="console-prompt">secops@aws-perimeter:~$</span>
|
|
||||||
<span class="cursor-block"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- CERTIFICADO DE AUDITORÍA FORENSE (Visible al contener la amenaza) -->
|
|
||||||
@if (showReport() || isContained()) {
|
|
||||||
<div class="forensic-report-card">
|
|
||||||
<header class="report-header">
|
|
||||||
<div class="report-header-left">
|
|
||||||
<span class="report-badge">{{ i18n.t().simReportDemoBadge }}</span>
|
|
||||||
<h3 class="report-title">{{ i18n.t().simReportTitle }}</h3>
|
|
||||||
</div>
|
|
||||||
<span class="report-status-pill">{{ i18n.t().simReportStatusSecure }}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="report-grid">
|
|
||||||
<div class="report-metric-box">
|
|
||||||
<span class="box-label">{{ i18n.t().simReportTargetEmail }}</span>
|
|
||||||
<span class="box-val highlight-mail">{{ targetRecipient() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="report-metric-box">
|
|
||||||
<span class="box-label">{{ i18n.t().simMetricLatency }}</span>
|
|
||||||
<span class="box-val highlight-time">{{ i18n.t().simReportTimeVsAvg }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="report-metric-box">
|
|
||||||
<span class="box-label">Integridad de Credenciales</span>
|
|
||||||
<span class="box-val">{{ i18n.t().simReportCredentialsCompromised }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="report-metric-box">
|
|
||||||
<span class="box-label">Impacto Financiero Evitado</span>
|
|
||||||
<span class="box-val success">{{ i18n.t().simReportFinancialLoss }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="report-footer">
|
|
||||||
<button type="button" class="download-report-btn" (click)="simulateDownload()">
|
|
||||||
<svg class="dl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
@if (downloadFeedback()) {
|
|
||||||
<span>✓ Reporte Forense Simulado con Éxito</span>
|
|
||||||
} @else {
|
|
||||||
<span>{{ i18n.t().simReportDownloadBtn }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Métricas de Arquitectura Serverless -->
|
|
||||||
<footer class="arch-metrics-bar">
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-label">{{ i18n.t().simMetricLatency }}</span>
|
|
||||||
<span class="metric-val highlight">< {{ executionTimeMs() }} ms</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-divider"></div>
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-label">{{ i18n.t().simMetricIntervention }}</span>
|
|
||||||
<span class="metric-val success">0.0 s (100% Auto)</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-divider"></div>
|
|
||||||
<div class="metric-card">
|
|
||||||
<span class="metric-label">{{ i18n.t().simMetricArchitecture }}</span>
|
|
||||||
<span class="metric-val">EventBridge + Lambda + SES</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -1,188 +1,83 @@
|
||||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnDestroy, OnInit } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { SecurityAgentService } from '../../../services/security-agent.service';
|
||||||
import { RetroAudioService } from '../../../application/services/retro-audio.service';
|
import { RetroAudioService } from '../../../application/services/retro-audio.service';
|
||||||
|
import {
|
||||||
export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained';
|
SimulationMode,
|
||||||
export type SimulatorViewMode = 'story' | 'tech';
|
SecurityAgentRequest,
|
||||||
|
SecurityAgentResponse
|
||||||
export interface SecOpsLog {
|
} from '../../../domain/models/security.model';
|
||||||
readonly timestamp: string;
|
|
||||||
readonly level: 'WARN' | 'INFO' | 'ACTION' | 'SUCCESS';
|
|
||||||
readonly tag: string;
|
|
||||||
readonly message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-incident-simulator',
|
selector: 'app-incident-simulator, app-cyber-defense',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './incident-simulator.component.html',
|
templateUrl: './incident-simulator.component.html',
|
||||||
styleUrl: './incident-simulator.component.css',
|
styleUrl: './incident-simulator.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class IncidentSimulatorComponent implements OnInit, OnDestroy {
|
export class IncidentSimulatorComponent {
|
||||||
protected readonly i18n = inject(I18nService);
|
private readonly securityService = inject(SecurityAgentService);
|
||||||
private readonly audio = inject(RetroAudioService);
|
private readonly audio = inject(RetroAudioService);
|
||||||
|
|
||||||
protected readonly stage = signal<SimulationStage>('idle');
|
// Parámetros reactivos de configuración
|
||||||
protected readonly viewMode = signal<SimulatorViewMode>('story');
|
readonly mode = signal<SimulationMode>('PURPLE_TEAM');
|
||||||
protected readonly userEmail = signal<string>('');
|
readonly target = signal<string>('local_sandbox');
|
||||||
protected readonly logs = signal<SecOpsLog[]>([]);
|
readonly requestedBy = signal<string>('auditor@bryan-bano.com');
|
||||||
protected readonly threatIp = signal<string>('198.51.100.42');
|
readonly autoIsolate = signal<boolean>(true);
|
||||||
protected readonly executionTimeMs = signal<number>(184);
|
|
||||||
protected readonly showReport = signal<boolean>(false);
|
|
||||||
protected readonly downloadFeedback = signal<boolean>(false);
|
|
||||||
|
|
||||||
protected readonly isSimulating = computed<boolean>(() => {
|
// Estados de ejecución y telemetría
|
||||||
const s = this.stage();
|
readonly isLoading = signal<boolean>(false);
|
||||||
return s === 'breach' || s === 'detection' || s === 'remediation' || s === 'notification';
|
readonly errorMessage = signal<string | null>(null);
|
||||||
});
|
readonly result = signal<SecurityAgentResponse | null>(null);
|
||||||
|
|
||||||
protected readonly isContained = computed<boolean>(() => this.stage() === 'contained');
|
setTargetPreset(value: string): void {
|
||||||
|
|
||||||
protected readonly targetRecipient = computed<string>(() => {
|
|
||||||
const email = this.userEmail().trim();
|
|
||||||
return email.length > 3 && email.includes('@') ? email : 'secops-team@bryan.dev';
|
|
||||||
});
|
|
||||||
|
|
||||||
private timeoutIds: ReturnType<typeof setTimeout>[] = [];
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.initDefaultLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
setViewMode(mode: SimulatorViewMode): void {
|
|
||||||
if (this.viewMode() === mode) return;
|
|
||||||
this.audio.playClick();
|
this.audio.playClick();
|
||||||
this.viewMode.set(mode);
|
this.target.set(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEmailInput(event: Event): void {
|
executeSimulation(): void {
|
||||||
const val = (event.target as HTMLInputElement).value;
|
if (this.isLoading()) return;
|
||||||
this.userEmail.set(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
startSimulation(): void {
|
|
||||||
if (this.isSimulating()) return;
|
|
||||||
|
|
||||||
this.clearTimeouts();
|
|
||||||
this.audio.playClick();
|
this.audio.playClick();
|
||||||
this.logs.set([]);
|
this.isLoading.set(true);
|
||||||
this.showReport.set(false);
|
this.errorMessage.set(null);
|
||||||
this.downloadFeedback.set(false);
|
this.result.set(null);
|
||||||
|
|
||||||
// Paso 1: Intento de Intrusión (Vector de Amenaza)
|
const payload: SecurityAgentRequest = {
|
||||||
this.stage.set('breach');
|
mode: this.mode(),
|
||||||
this.appendLog({
|
target: this.target().trim() || 'local_sandbox',
|
||||||
level: 'WARN',
|
requested_by: this.requestedBy().trim() || 'auditor@bryan-bano.com',
|
||||||
tag: 'IAM-ALERT',
|
parameters: {
|
||||||
message: `Anomalous API request: iam:CreateAccessKey from untrusted IP ${this.threatIp()} for principal "svc-deployer".`
|
auto_isolate: this.autoIsolate(),
|
||||||
|
scan_depth: 'full'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.securityService.triggerSimulation(payload).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.result.set(response);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
this.audio.playVictory();
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
this.errorMessage.set(err.message || 'Error al conectar con el orquestador n8n.');
|
||||||
|
this.isLoading.set(false);
|
||||||
|
this.audio.playGameOver();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paso 2: Detección Inmediata en EventBridge & CloudTrail (después de 700ms)
|
|
||||||
const t1 = setTimeout(() => {
|
|
||||||
this.stage.set('detection');
|
|
||||||
this.audio.playCellReveal();
|
|
||||||
this.appendLog({
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'CLOUDTRAIL',
|
|
||||||
message: 'Management event ingested in us-east-1. EventBridge rule "SecOps-AutoRemediate" triggered synchronously.'
|
|
||||||
});
|
|
||||||
}, 700);
|
|
||||||
this.timeoutIds.push(t1);
|
|
||||||
|
|
||||||
// Paso 3: Remediación Automatizada Serverless (después de 1500ms)
|
|
||||||
const t2 = setTimeout(() => {
|
|
||||||
this.stage.set('remediation');
|
|
||||||
this.audio.playFlagToggle();
|
|
||||||
this.appendLog({
|
|
||||||
level: 'ACTION',
|
|
||||||
tag: 'LAMBDA',
|
|
||||||
message: 'Executing fn-iam-incident-response: Revoking active STS sessions & attaching inline DenyAllSecurityPolicy.'
|
|
||||||
});
|
|
||||||
}, 1500);
|
|
||||||
this.timeoutIds.push(t2);
|
|
||||||
|
|
||||||
// Paso 4: Despacho de Reporte Forense (después de 2300ms)
|
|
||||||
const t3 = setTimeout(() => {
|
|
||||||
this.stage.set('notification');
|
|
||||||
this.audio.playCellReveal();
|
|
||||||
this.appendLog({
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'SES-ALERT',
|
|
||||||
message: `Dispatching cryptographic audit report to ${this.targetRecipient()} via Amazon SES.`
|
|
||||||
});
|
|
||||||
}, 2300);
|
|
||||||
this.timeoutIds.push(t3);
|
|
||||||
|
|
||||||
// Paso 5: Amenaza Contenida & Generación de Reporte (después de 3100ms)
|
|
||||||
const t4 = setTimeout(() => {
|
|
||||||
this.stage.set('contained');
|
|
||||||
this.audio.playVictory();
|
|
||||||
this.showReport.set(true);
|
|
||||||
this.appendLog({
|
|
||||||
level: 'SUCCESS',
|
|
||||||
tag: 'CONTAINED',
|
|
||||||
message: `Threat neutralized successfully in ${this.executionTimeMs()}ms. Zero credentials compromised. State: SECURE.`
|
|
||||||
});
|
|
||||||
}, 3100);
|
|
||||||
this.timeoutIds.push(t4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSimulation(): void {
|
resetSimulation(): void {
|
||||||
this.clearTimeouts();
|
|
||||||
this.audio.playClick();
|
this.audio.playClick();
|
||||||
this.stage.set('idle');
|
this.errorMessage.set(null);
|
||||||
this.showReport.set(false);
|
this.result.set(null);
|
||||||
this.downloadFeedback.set(false);
|
this.isLoading.set(false);
|
||||||
this.initDefaultLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
simulateDownload(): void {
|
|
||||||
this.audio.playClick();
|
|
||||||
this.downloadFeedback.set(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.downloadFeedback.set(false);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private initDefaultLogs(): void {
|
|
||||||
const time = this.getCurrentTimestamp();
|
|
||||||
this.logs.set([
|
|
||||||
{
|
|
||||||
timestamp: time,
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'GUARDDUTY',
|
|
||||||
message: 'Cloud perimeter telemetry nominal. 0 active security findings in us-east-1.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamp: time,
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'EVENTBRIDGE',
|
|
||||||
message: 'Active rule pattern matched: Rule-SecOps-IAM-AutoRemediation [ENABLED].'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendLog(entry: Omit<SecOpsLog, 'timestamp'>): void {
|
|
||||||
const timestamp = this.getCurrentTimestamp();
|
|
||||||
this.logs.update(prev => [...prev, { ...entry, timestamp }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCurrentTimestamp(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const h = String(now.getHours()).padStart(2, '0');
|
|
||||||
const m = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const s = String(now.getSeconds()).padStart(2, '0');
|
|
||||||
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
||||||
return `${h}:${m}:${s}.${ms}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearTimeouts(): void {
|
|
||||||
this.timeoutIds.forEach(id => clearTimeout(id));
|
|
||||||
this.timeoutIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.clearTimeouts();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
IncidentSimulatorComponent as CyberDefenseComponent,
|
||||||
|
IncidentSimulatorComponent as CyberDefenseDashboardComponent
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue