diff --git a/opt/torero-ui/torero_ui/static/css/dashboard.css b/opt/torero-ui/torero_ui/static/css/dashboard.css
index 61b7267..96d3d77 100644
--- a/opt/torero-ui/torero_ui/static/css/dashboard.css
+++ b/opt/torero-ui/torero_ui/static/css/dashboard.css
@@ -421,6 +421,1216 @@ body {
}
}
+/* opentofu output styling with brand theme */
+.opentofu-tabs {
+ margin-top: 20px;
+ background-color: #000000;
+ border: 1px solid #5cfcfe;
+ border-radius: 4px;
+ padding: 15px;
+}
+
+.opentofu-tabs .tab-buttons {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 15px;
+ border-bottom: 2px solid #5cfcfe;
+ padding-bottom: 10px;
+}
+
+.opentofu-tabs .tab-button {
+ background: #111111;
+ border: 1px solid #333333;
+ color: #ffffff;
+ font-family: 'Consolas', monospace;
+ font-size: 13px;
+ padding: 8px 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-transform: none;
+ letter-spacing: 0.5px;
+ border-radius: 4px 4px 0 0;
+}
+
+.opentofu-tabs .tab-button:hover {
+ background: #222222;
+ color: #5cfcfe;
+ border-color: #5cfcfe;
+}
+
+.opentofu-tabs .tab-button.active {
+ background: #222222;
+ color: #ccff00;
+ border-color: #ccff00;
+ border-bottom: 2px solid #222222;
+ margin-bottom: -2px;
+}
+
+.opentofu-tab-content {
+ display: none;
+ padding: 20px;
+ background: #0a0a0a;
+ border-radius: 0 0 4px 4px;
+}
+
+.opentofu-tab-content.active {
+ display: block;
+}
+
+/* console output styling */
+.console-section {
+ margin-bottom: 20px;
+}
+
+.console-header {
+ color: #5cfcfe;
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 10px;
+ padding: 8px;
+ background: #111111;
+ border-left: 3px solid #5cfcfe;
+}
+
+.console-header-error {
+ color: #ccff00;
+ border-left-color: #ccff00;
+}
+
+.tofu-console-output {
+ background: #1d2021;
+ color: #ebdbb2;
+ padding: 15px;
+ border-radius: 4px;
+ overflow-x: auto;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 13px;
+ line-height: 1.6;
+ white-space: pre;
+ border: 1px solid #3c3836;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.tofu-console-error {
+ border-left: 3px solid #fb4934;
+}
+
+/* state file display */
+.state-container {
+ padding: 10px;
+}
+
+.state-summary {
+ background: #111111;
+ border: 1px solid #333333;
+ border-radius: 4px;
+ padding: 15px;
+ margin-bottom: 20px;
+}
+
+.state-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #333333;
+}
+
+.state-metadata {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 15px;
+}
+
+.metadata-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px;
+ background: #000000;
+ border-radius: 3px;
+ border: 1px solid #333333;
+}
+
+.metadata-label {
+ color: #888888;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+.metadata-value {
+ color: #ccff00;
+ font-weight: bold;
+}
+
+/* resources grid */
+.resources-section {
+ margin-top: 20px;
+}
+
+.resource-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 15px;
+ margin-top: 15px;
+}
+
+.resource-card {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.resource-card:hover {
+ border-color: #5cfcfe;
+ background: #222222;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(92, 252, 254, 0.1);
+}
+
+.resource-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #333333;
+}
+
+.resource-type {
+ color: #ccff00;
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.resource-mode {
+ color: #888888;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.resource-name {
+ color: #5cfcfe;
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 8px;
+}
+
+.resource-details {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+}
+
+.resource-provider {
+ color: #5cfcfe;
+}
+
+.resource-instances {
+ color: #ccff00;
+}
+
+/* outputs display */
+.outputs-container {
+ padding: 10px;
+}
+
+.outputs-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #333333;
+}
+
+.outputs-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.output-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.output-key {
+ color: #5cfcfe;
+ font-weight: bold;
+ font-size: 14px;
+}
+
+.output-value-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.output-value {
+ color: #ccff00;
+ word-break: break-word;
+ font-family: 'Consolas', monospace;
+ flex: 1;
+}
+
+.output-type {
+ color: #888888;
+ font-size: 11px;
+ margin-left: 10px;
+ white-space: nowrap;
+}
+
+/* timing information */
+.timing-container {
+ padding: 10px;
+}
+
+.timing-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #333333;
+}
+
+.timing-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 15px;
+}
+
+.timing-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.timing-label {
+ color: #888888;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+.timing-value {
+ color: #5cfcfe;
+ font-weight: bold;
+}
+
+.timing-success {
+ color: #ccff00 !important;
+}
+
+.timing-error {
+ color: #ff4444 !important;
+}
+
+/* no output message */
+.no-output {
+ text-align: center;
+ padding: 40px;
+ color: #888888;
+ font-style: italic;
+ background: #111111;
+ border: 1px dashed #333333;
+ border-radius: 4px;
+}
+
+/* scrollbar styling for brand theme */
+.tofu-console-output::-webkit-scrollbar,
+.opentofu-tab-content::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+.tofu-console-output::-webkit-scrollbar-track,
+.opentofu-tab-content::-webkit-scrollbar-track {
+ background: #504945;
+ border-radius: 5px;
+}
+
+.tofu-console-output::-webkit-scrollbar-thumb,
+.opentofu-tab-content::-webkit-scrollbar-thumb {
+ background: #665c54;
+ border-radius: 5px;
+}
+
+.tofu-console-output::-webkit-scrollbar-thumb:hover,
+.opentofu-tab-content::-webkit-scrollbar-thumb:hover {
+ background: #7c6f64;
+}
+
+/* python script output styling with brand theme */
+.python-tabs {
+ margin-top: 20px;
+ background-color: #000000;
+ border: 1px solid #5cfcfe;
+ border-radius: 4px;
+ padding: 15px;
+}
+
+.python-tabs .tab-buttons {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 15px;
+ border-bottom: 2px solid #5cfcfe;
+ padding-bottom: 10px;
+}
+
+.python-tabs .tab-button {
+ background: #111111;
+ border: 1px solid #333333;
+ color: #ffffff;
+ font-family: 'Consolas', monospace;
+ font-size: 13px;
+ padding: 8px 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-transform: none;
+ letter-spacing: 0.5px;
+ border-radius: 4px 4px 0 0;
+}
+
+.python-tabs .tab-button:hover {
+ background: #222222;
+ color: #5cfcfe;
+ border-color: #5cfcfe;
+}
+
+.python-tabs .tab-button.active {
+ background: #222222;
+ color: #ccff00;
+ border-color: #ccff00;
+ border-bottom: 2px solid #222222;
+ margin-bottom: -2px;
+}
+
+.python-tab-content {
+ display: none;
+ padding: 20px;
+ background: #0a0a0a;
+ border-radius: 0 0 4px 4px;
+}
+
+.python-tab-content.active {
+ display: block;
+}
+
+/* python console output styling with gruvbox */
+.python-console-output {
+ background: #1d2021;
+ color: #ebdbb2;
+ padding: 15px;
+ border-radius: 4px;
+ overflow-x: auto;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 13px;
+ line-height: 1.6;
+ white-space: pre;
+ border: 1px solid #3c3836;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.python-console-error {
+ border-left: 3px solid #fb4934;
+}
+
+/* python log level colors using gruvbox */
+.python-critical { color: #cc241d; font-weight: bold; }
+.python-error { color: #fb4934; }
+.python-warning { color: #d79921; }
+.python-info { color: #458588; }
+.python-debug { color: #928374; }
+.python-success { color: #98971a; }
+.python-json { color: #83a598; }
+.python-file { color: #83a598; text-decoration: underline; }
+.python-line { color: #fabd2f; font-weight: bold; }
+.python-timing { color: #8ec07c; font-weight: bold; }
+
+/* python stack trace styling */
+.stacktrace-container {
+ padding: 10px;
+}
+
+.stacktrace-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.stacktrace-item {
+ background: #111111;
+ border: 1px solid #333333;
+ margin-bottom: 15px;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stacktrace-title {
+ background: #5cfcfe;
+ color: #000000;
+ padding: 8px 12px;
+ font-weight: bold;
+ font-size: 12px;
+}
+
+.stacktrace-content {
+ padding: 15px;
+ background: #1d2021;
+ color: #ebdbb2;
+ font-family: 'Consolas', monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ overflow-x: auto;
+}
+
+.stacktrace-file { color: #83a598; }
+.stacktrace-line { color: #fabd2f; font-weight: bold; }
+.stacktrace-function { color: #b16286; }
+.stacktrace-exception { color: #fb4934; font-weight: bold; }
+.stacktrace-header-text { color: #fb4934; font-weight: bold; }
+
+/* python performance styling */
+.performance-container {
+ padding: 10px;
+}
+
+.performance-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.performance-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.performance-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.performance-label {
+ color: #888888;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+.performance-value {
+ color: #5cfcfe;
+ font-weight: bold;
+}
+
+.performance-success {
+ color: #ccff00 !important;
+}
+
+.performance-error {
+ color: #ff4444 !important;
+}
+
+.performance-subheader {
+ color: #5cfcfe;
+ font-size: 13px;
+ font-weight: bold;
+ margin: 15px 0 10px 0;
+}
+
+.timing-context {
+ background: #111111;
+ border: 1px solid #333333;
+ margin-bottom: 10px;
+ padding: 10px;
+ border-radius: 4px;
+}
+
+/* python environment styling */
+.environment-container {
+ padding: 10px;
+}
+
+.environment-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.env-section {
+ margin-bottom: 20px;
+}
+
+.env-subheader {
+ color: #ccff00;
+ font-size: 13px;
+ font-weight: bold;
+ margin-bottom: 10px;
+}
+
+.env-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+}
+
+.env-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 8px;
+ border-radius: 3px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.env-label {
+ color: #888888;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+.env-value {
+ color: #ffffff;
+ font-weight: bold;
+}
+
+.import-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.import-item {
+ background: #111111;
+ border: 1px solid #5cfcfe;
+ color: #ccff00;
+ padding: 4px 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-family: 'Consolas', monospace;
+}
+
+.module-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.module-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 8px;
+ border-radius: 3px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.module-name {
+ color: #5cfcfe;
+ font-weight: bold;
+}
+
+.module-version {
+ color: #ccff00;
+ font-size: 11px;
+ font-family: 'Consolas', monospace;
+}
+
+/* ansible playbook output styling with brand theme */
+.ansible-tabs {
+ margin-top: 20px;
+ background-color: #000000;
+ border: 1px solid #5cfcfe;
+ border-radius: 4px;
+ padding: 15px;
+}
+
+.ansible-tabs .tab-buttons {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 15px;
+ border-bottom: 2px solid #5cfcfe;
+ padding-bottom: 10px;
+}
+
+.ansible-tabs .tab-button {
+ background: #111111;
+ border: 1px solid #333333;
+ color: #ffffff;
+ font-family: 'Consolas', monospace;
+ font-size: 13px;
+ padding: 8px 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-transform: none;
+ letter-spacing: 0.5px;
+ border-radius: 4px 4px 0 0;
+}
+
+.ansible-tabs .tab-button:hover {
+ background: #222222;
+ color: #5cfcfe;
+ border-color: #5cfcfe;
+}
+
+.ansible-tabs .tab-button.active {
+ background: #222222;
+ color: #ccff00;
+ border-color: #ccff00;
+ border-bottom: 2px solid #222222;
+ margin-bottom: -2px;
+}
+
+.ansible-tab-content {
+ display: none;
+ padding: 20px;
+ background: #0a0a0a;
+ border-radius: 0 0 4px 4px;
+}
+
+.ansible-tab-content.active {
+ display: block;
+}
+
+/* ansible console output styling with gruvbox */
+.ansible-console-output {
+ background: #1d2021;
+ color: #ebdbb2;
+ padding: 15px;
+ border-radius: 4px;
+ overflow-x: auto;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 13px;
+ line-height: 1.6;
+ white-space: pre;
+ border: 1px solid #3c3836;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.ansible-console-error {
+ border-left: 3px solid #fb4934;
+}
+
+/* ansible status colors using gruvbox */
+.ansible-ok { color: #98971a; }
+.ansible-changed { color: #d79921; }
+.ansible-failed { color: #fb4934; }
+.ansible-skipped { color: #458588; }
+.ansible-unreachable { color: #928374; }
+.ansible-success { color: #98971a; }
+.ansible-recap { color: #ebdbb2; font-weight: bold; }
+.ansible-task { color: #458588; font-weight: bold; }
+.ansible-task-name { color: #ebdbb2; }
+.ansible-host { color: #d79921; font-weight: bold; }
+.ansible-timing { color: #8ec07c; font-weight: bold; }
+.ansible-json { color: #83a598; }
+.ansible-yaml { color: #b16286; }
+
+/* ansible summary styling */
+.ansible-summary-container {
+ padding: 10px;
+}
+
+.ansible-summary-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.ansible-overview {
+ margin-bottom: 20px;
+}
+
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.summary-item {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.summary-label {
+ color: #888888;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+.summary-value {
+ color: #ffffff;
+ font-weight: bold;
+}
+
+.ansible-section-header {
+ color: #ccff00;
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #333333;
+}
+
+.host-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 15px;
+}
+
+.host-summary-card {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+}
+
+.host-name {
+ color: #5cfcfe;
+ font-weight: bold;
+ font-size: 14px;
+ margin-bottom: 8px;
+}
+
+.host-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.stat-item {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 2px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.plays-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.play-item {
+ background: #1a1a1a;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+}
+
+.play-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 5px;
+}
+
+.play-name {
+ color: #ccff00;
+ font-weight: bold;
+}
+
+.play-status {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 2px;
+}
+
+.play-status.ok {
+ background: #ccff00;
+ color: #000;
+}
+
+.play-status.failed {
+ background: #ff4444;
+ color: #fff;
+}
+
+.play-details {
+ display: flex;
+ gap: 15px;
+ font-size: 12px;
+ color: #c9c9c9;
+}
+
+/* ansible tasks styling */
+.ansible-tasks-container {
+ padding: 10px;
+}
+
+.ansible-tasks-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.tasks-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.task-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+}
+
+.task-indicator {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-top: 6px;
+ flex-shrink: 0;
+}
+
+.task-indicator.ok { background: #00d924; }
+.task-indicator.changed { background: #ff8c00; }
+.task-indicator.failed { background: #ff073a; }
+.task-indicator.skipped { background: #0088cc; }
+
+.task-content {
+ flex: 1;
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 12px;
+ border-radius: 4px;
+}
+
+.task-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.task-name {
+ color: #ffffff;
+ font-weight: bold;
+}
+
+.task-status {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 2px;
+}
+
+.task-details {
+ display: flex;
+ gap: 15px;
+ margin-bottom: 8px;
+ font-size: 12px;
+}
+
+.task-module {
+ color: #5cfcfe;
+ font-family: 'Consolas', monospace;
+}
+
+.task-duration {
+ color: #ccff00;
+}
+
+.task-hosts {
+ margin-bottom: 8px;
+}
+
+.hosts-label {
+ color: #c9c9c9;
+ font-size: 11px;
+ margin-right: 8px;
+}
+
+.host-tag {
+ background: #5cfcfe;
+ color: #000;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-size: 10px;
+ margin-right: 4px;
+}
+
+.task-changes {
+ margin-top: 10px;
+}
+
+.changes-header {
+ color: #ccff00;
+ font-size: 12px;
+ margin-bottom: 5px;
+}
+
+.changes-content {
+ background: #000000;
+ border: 1px solid #333333;
+ padding: 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+/* ansible hosts styling */
+.ansible-hosts-container {
+ padding: 10px;
+}
+
+.ansible-hosts-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.hosts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 15px;
+}
+
+.host-result-card {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 15px;
+ border-radius: 4px;
+}
+
+.host-result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #333333;
+}
+
+.host-result-name {
+ color: #5cfcfe;
+ font-weight: bold;
+ font-size: 16px;
+}
+
+.host-result-status {
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 3px;
+ font-weight: bold;
+}
+
+.host-result-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.result-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 6px;
+ background: #000000;
+ border-radius: 3px;
+}
+
+.result-stat .stat-label {
+ color: #888888;
+ font-size: 10px;
+ text-transform: uppercase;
+}
+
+.result-stat .stat-value {
+ font-weight: bold;
+ font-size: 14px;
+}
+
+.host-tasks {
+ border-top: 1px solid #333333;
+ padding-top: 10px;
+}
+
+.host-tasks-header {
+ color: #ccff00;
+ font-size: 12px;
+ font-weight: bold;
+ margin-bottom: 8px;
+}
+
+.host-tasks-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.host-task-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px;
+ font-size: 11px;
+}
+
+.host-task-item .task-indicator {
+ width: 6px;
+ height: 6px;
+ margin-top: 0;
+}
+
+.task-text {
+ color: #d4d4d4;
+}
+
+.more-tasks {
+ color: #c9c9c9;
+ font-size: 10px;
+ font-style: italic;
+ margin-top: 4px;
+}
+
+/* ansible variables styling */
+.ansible-variables-container {
+ padding: 10px;
+}
+
+.ansible-variables-header {
+ color: #5cfcfe;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #5cfcfe;
+}
+
+.variables-sections {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.variable-section {
+ background: #111111;
+ border: 1px solid #333333;
+ padding: 15px;
+ border-radius: 4px;
+}
+
+.variable-category {
+ color: #ccff00;
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 12px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid #333333;
+}
+
+.variables-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.variable-item {
+ background: #000000;
+ border: 1px solid #333333;
+ padding: 10px;
+ border-radius: 3px;
+}
+
+.variable-key {
+ color: #5cfcfe;
+ font-weight: bold;
+ font-size: 12px;
+ margin-bottom: 4px;
+}
+
+.variable-value {
+ color: #ffffff;
+ font-family: 'Consolas', monospace;
+ font-size: 11px;
+ word-break: break-word;
+}
+
+.more-vars {
+ color: #888888;
+ font-size: 11px;
+ font-style: italic;
+ text-align: center;
+ padding: 8px;
+}
+
+/* scrollbar styling for code output */
+.python-console-output::-webkit-scrollbar,
+.python-tab-content::-webkit-scrollbar,
+.ansible-console-output::-webkit-scrollbar,
+.ansible-tab-content::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+.python-console-output::-webkit-scrollbar-track,
+.python-tab-content::-webkit-scrollbar-track,
+.ansible-console-output::-webkit-scrollbar-track,
+.ansible-tab-content::-webkit-scrollbar-track {
+ background: #504945;
+ border-radius: 5px;
+}
+
+.python-console-output::-webkit-scrollbar-thumb,
+.python-tab-content::-webkit-scrollbar-thumb,
+.ansible-console-output::-webkit-scrollbar-thumb,
+.ansible-tab-content::-webkit-scrollbar-thumb {
+ background: #665c54;
+ border-radius: 5px;
+}
+
+.python-console-output::-webkit-scrollbar-thumb:hover,
+.python-tab-content::-webkit-scrollbar-thumb:hover,
+.ansible-console-output::-webkit-scrollbar-thumb:hover,
+.ansible-tab-content::-webkit-scrollbar-thumb:hover {
+ background: #7c6f64;
+}
+
/* utility classes */
.text-center { text-align: center; }
.text-success { color: #ccff00; }
diff --git a/opt/torero-ui/torero_ui/static/js/dashboard.js b/opt/torero-ui/torero_ui/static/js/dashboard.js
index 5e99a65..f966cfa 100644
--- a/opt/torero-ui/torero_ui/static/js/dashboard.js
+++ b/opt/torero-ui/torero_ui/static/js/dashboard.js
@@ -369,28 +369,45 @@ function generateExecutionDetailHTML(execution) {
html += '';
- // add stdout if available
- if (execution.stdout) {
- html += `
-
Standard Output
- ${escapeHtml(execution.stdout)}
- `;
- }
-
- // add stderr if available
- if (execution.stderr) {
- html += `
- Standard Error
- ${escapeHtml(execution.stderr)}
- `;
- }
-
- // add execution data if available
- if (execution.execution_data && Object.keys(execution.execution_data).length > 0) {
- html += `
- Execution Data
- ${escapeHtml(JSON.stringify(execution.execution_data, null, 2))}
- `;
+ // check service type and use appropriate formatter
+ if (execution.service_type === 'opentofu-plan' && execution.execution_data &&
+ typeof execution.execution_data === 'object' &&
+ (execution.execution_data.stdout || execution.execution_data.state_file)) {
+ // use specialized opentofu formatter
+ html += formatOpenTofuOutput(execution.execution_data);
+ } else if (execution.service_type === 'python-script' && execution.execution_data &&
+ typeof execution.execution_data === 'object') {
+ // use specialized python formatter
+ html += formatPythonOutput(execution.execution_data);
+ } else if (execution.service_type === 'ansible-playbook' && execution.execution_data &&
+ typeof execution.execution_data === 'object') {
+ // use specialized ansible formatter
+ html += formatAnsibleOutput(execution.execution_data);
+ } else {
+ // use standard output display for other service types
+ // add stdout if available
+ if (execution.stdout) {
+ html += `
+ Standard Output
+ ${escapeHtml(execution.stdout)}
+ `;
+ }
+
+ // add stderr if available
+ if (execution.stderr) {
+ html += `
+ Standard Error
+ ${escapeHtml(execution.stderr)}
+ `;
+ }
+
+ // add execution data if available
+ if (execution.execution_data && Object.keys(execution.execution_data).length > 0) {
+ html += `
+ Execution Data
+ ${escapeHtml(JSON.stringify(execution.execution_data, null, 2))}
+ `;
+ }
}
return html;
@@ -450,6 +467,1188 @@ function escapeHtml(text) {
return div.innerHTML;
}
+// opentofu output formatting functions
+function formatOpenTofuOutput(executionData) {
+ // parse the data if it's a string
+ const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData;
+
+ // generate unique ids for tabs within this execution
+ const tabPrefix = 'tofu-' + Date.now();
+
+ return `
+
+
+ Console Output
+ State
+ Outputs
+ Timing
+ Raw Data
+
+
+
+ ${formatConsoleOutput(data)}
+
+
+
+ ${formatStateFile(data.state_file)}
+
+
+
+ ${formatTofuOutputs(data.state_file?.outputs)}
+
+
+
+ ${formatTimingInfo(data)}
+
+
+
+
${escapeHtml(JSON.stringify(data, null, 2))}
+
+
+ `;
+}
+
+// format console output with ansi to html conversion using dracula theme
+function formatConsoleOutput(data) {
+ if (!data.stdout && !data.stderr) {
+ return 'No console output available
';
+ }
+
+ let html = '';
+
+ if (data.stdout) {
+ const formattedStdout = convertAnsiToHtml(data.stdout);
+ html += `
+
+ `;
+ }
+
+ if (data.stderr) {
+ const formattedStderr = convertAnsiToHtml(data.stderr);
+ html += `
+
+ `;
+ }
+
+ return html;
+}
+
+// convert ansi escape codes to html with gruvbox theme colors
+function convertAnsiToHtml(text) {
+ if (!text) return '';
+
+ // gruvbox dark theme colors
+ const gruvbox = {
+ black: '#282828',
+ red: '#cc241d',
+ green: '#98971a',
+ yellow: '#d79921',
+ blue: '#458588',
+ magenta: '#b16286',
+ cyan: '#689d6a',
+ white: '#a89984',
+ brightBlack: '#928374',
+ brightRed: '#fb4934',
+ brightGreen: '#b8bb26',
+ brightYellow: '#fabd2f',
+ brightBlue: '#83a598',
+ brightMagenta: '#d3869b',
+ brightCyan: '#8ec07c',
+ brightWhite: '#ebdbb2'
+ };
+
+ // first escape html to prevent xss
+ let result = escapeHtml(text);
+
+ // replace ansi color codes with html spans
+ // handle 8-color and 16-color ansi codes
+ result = result
+ // reset
+ .replace(/\x1b\[0m/g, '')
+ .replace(/\x1b\[m/g, '')
+
+ // bold/bright
+ .replace(/\x1b\[1m/g, '')
+ .replace(/\x1b\[22m/g, ' ')
+
+ // dim
+ .replace(/\x1b\[2m/g, '')
+
+ // italic
+ .replace(/\x1b\[3m/g, '')
+ .replace(/\x1b\[23m/g, ' ')
+
+ // underline
+ .replace(/\x1b\[4m/g, '')
+ .replace(/\x1b\[24m/g, ' ')
+
+ // foreground colors (30-37, 90-97)
+ .replace(/\x1b\[30m/g, ``)
+ .replace(/\x1b\[31m/g, ``)
+ .replace(/\x1b\[32m/g, ``)
+ .replace(/\x1b\[33m/g, ``)
+ .replace(/\x1b\[34m/g, ``)
+ .replace(/\x1b\[35m/g, ``)
+ .replace(/\x1b\[36m/g, ``)
+ .replace(/\x1b\[37m/g, ``)
+
+ // bright foreground colors
+ .replace(/\x1b\[90m/g, ``)
+ .replace(/\x1b\[91m/g, ``)
+ .replace(/\x1b\[92m/g, ``)
+ .replace(/\x1b\[93m/g, ``)
+ .replace(/\x1b\[94m/g, ``)
+ .replace(/\x1b\[95m/g, ``)
+ .replace(/\x1b\[96m/g, ``)
+ .replace(/\x1b\[97m/g, ``)
+
+ // background colors (40-47, 100-107)
+ .replace(/\x1b\[40m/g, ``)
+ .replace(/\x1b\[41m/g, ``)
+ .replace(/\x1b\[42m/g, ``)
+ .replace(/\x1b\[43m/g, ``)
+ .replace(/\x1b\[44m/g, ``)
+ .replace(/\x1b\[45m/g, ``)
+ .replace(/\x1b\[46m/g, ``)
+ .replace(/\x1b\[47m/g, ``)
+
+ // handle combined codes like \x1b[1;32m (bold green)
+ .replace(/\x1b\[1;30m/g, ``)
+ .replace(/\x1b\[1;31m/g, ``)
+ .replace(/\x1b\[1;32m/g, ``)
+ .replace(/\x1b\[1;33m/g, ``)
+ .replace(/\x1b\[1;34m/g, ``)
+ .replace(/\x1b\[1;35m/g, ``)
+ .replace(/\x1b\[1;36m/g, ``)
+ .replace(/\x1b\[1;37m/g, ``)
+
+ // handle any remaining escape sequences
+ .replace(/\x1b\[[0-9;]*m/g, '');
+
+ // clean up any unclosed spans at the end
+ const openSpans = (result.match(/]*>/g) || []).length;
+ const closeSpans = (result.match(/<\/span>/g) || []).length;
+ if (openSpans > closeSpans) {
+ result += ' '.repeat(openSpans - closeSpans);
+ }
+
+ return result;
+}
+
+// format state file information
+function formatStateFile(stateFile) {
+ if (!stateFile) {
+ return 'No state information available
';
+ }
+
+ const resources = stateFile.resources || [];
+ const outputs = stateFile.outputs || {};
+
+ return `
+
+
+
+ ${resources.length > 0 ? formatResources(resources) : ''}
+
+ `;
+}
+
+// format resource list from state file
+function formatResources(resources) {
+ if (!resources || resources.length === 0) {
+ return '';
+ }
+
+ return `
+
+
+
+ ${resources.map(resource => `
+
+
+
${resource.name || 'unnamed'}
+
+ ${extractProviderName(resource.provider)}
+ Instances: ${resource.instances ? resource.instances.length : 0}
+
+
+ `).join('')}
+
+
+ `;
+}
+
+// extract provider name from full provider string
+function extractProviderName(provider) {
+ if (!provider) return 'unknown';
+ // extract from format like: provider["registry.opentofu.org/hashicorp/null"]
+ const match = provider.match(/provider\["[^/]*\/([^/]*)\/([^"]*)/);
+ if (match) {
+ return `${match[1]}/${match[2]}`;
+ }
+ return provider;
+}
+
+// format tofu outputs
+function formatTofuOutputs(outputs) {
+ if (!outputs || Object.keys(outputs).length === 0) {
+ return 'No outputs defined
';
+ }
+
+ return `
+
+
+
+ ${Object.entries(outputs).map(([key, value]) => `
+
+
${key}
+
+ ${formatOutputValue(value.value)}
+ ${value.type || 'unknown'}
+
+
+ `).join('')}
+
+
+ `;
+}
+
+// format output value based on type
+function formatOutputValue(value) {
+ if (value === null || value === undefined) {
+ return 'null';
+ }
+ if (typeof value === 'object') {
+ return JSON.stringify(value, null, 2);
+ }
+ return String(value);
+}
+
+// format timing information
+function formatTimingInfo(data) {
+ if (!data.start_time && !data.end_time && !data.elapsed_time) {
+ return 'No timing information available
';
+ }
+
+ const startTime = data.start_time ? new Date(data.start_time) : null;
+ const endTime = data.end_time ? new Date(data.end_time) : null;
+
+ return `
+
+
+
+ ${startTime ? `
+
+ Started:
+ ${startTime.toLocaleString()}
+
+ ` : ''}
+ ${endTime ? `
+
+ Completed:
+ ${endTime.toLocaleString()}
+
+ ` : ''}
+ ${data.elapsed_time ? `
+
+ Duration:
+ ${data.elapsed_time.toFixed(3)} seconds
+
+ ` : ''}
+ ${data.return_code !== undefined ? `
+
+ Return Code:
+ ${data.return_code}
+
+ ` : ''}
+
+
+ `;
+}
+
+// switch between opentofu output tabs
+function switchOpenTofuTab(tabId, button) {
+ // find the parent container
+ const container = button.closest('.opentofu-tabs');
+
+ // hide all tab contents in this container
+ const contents = container.querySelectorAll('.opentofu-tab-content');
+ contents.forEach(content => {
+ content.classList.remove('active');
+ });
+
+ // remove active class from all buttons in this container
+ const buttons = container.querySelectorAll('.tab-button');
+ buttons.forEach(btn => {
+ btn.classList.remove('active');
+ });
+
+ // show selected tab
+ const targetTab = document.getElementById(tabId);
+ if (targetTab) {
+ targetTab.classList.add('active');
+ }
+
+ // mark button as active
+ button.classList.add('active');
+}
+
+// python script output formatting functions
+function formatPythonOutput(executionData) {
+ const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData;
+ const tabPrefix = 'python-' + Date.now();
+
+ return `
+
+
+ Console Output
+ Stack Trace
+ Performance
+ Environment
+ Raw Data
+
+
+
+ ${formatPythonConsoleOutput(data)}
+
+
+
+ ${formatPythonStackTrace(data)}
+
+
+
+ ${formatPythonPerformance(data)}
+
+
+
+ ${formatPythonEnvironment(data)}
+
+
+
+
${escapeHtml(JSON.stringify(data, null, 2))}
+
+
+ `;
+}
+
+// format python console output with log level detection
+function formatPythonConsoleOutput(data) {
+ if (!data.stdout && !data.stderr) {
+ return 'No console output available
';
+ }
+
+ let html = '';
+
+ if (data.stdout) {
+ const formattedStdout = formatPythonLogOutput(data.stdout);
+ html += `
+
+ `;
+ }
+
+ if (data.stderr) {
+ const formattedStderr = formatPythonLogOutput(data.stderr);
+ html += `
+
+ `;
+ }
+
+ return html;
+}
+
+// format python output with log level detection and syntax highlighting
+function formatPythonLogOutput(text) {
+ if (!text) return '';
+
+ let result = escapeHtml(text);
+
+ // python log level patterns with colors
+ result = result
+ .replace(/\b(CRITICAL|FATAL)(\s*[:\-]|\b)/gi, '$1$2 ')
+ .replace(/\b(ERROR)(\s*[:\-]|\b)/gi, '$1$2 ')
+ .replace(/\b(WARNING|WARN)(\s*[:\-]|\b)/gi, '$1$2 ')
+ .replace(/\b(INFO)(\s*[:\-]|\b)/gi, '$1$2 ')
+ .replace(/\b(DEBUG)(\s*[:\-]|\b)/gi, '$1$2 ')
+
+ // highlight json objects
+ .replace(/(\{[^{}]*\})/g, '$1 ')
+
+ // highlight file paths and line numbers
+ .replace(/(\w+\.py):(\d+)/g, '$1 :$2 ')
+
+ // highlight execution times
+ .replace(/(\d+\.?\d*)\s*(s|ms|seconds?|milliseconds?)\b/gi, '$1$2 ')
+
+ // highlight success indicators
+ .replace(/\b(SUCCESS|PASSED|OK|COMPLETE)\b/gi, '$1 ')
+
+ // highlight failure indicators
+ .replace(/\b(FAILED?|FAILURE|ERROR|EXCEPTION|TRACEBACK)\b/gi, '$1 ');
+
+ return result;
+}
+
+// format python stack trace with file links
+function formatPythonStackTrace(data) {
+ const text = data.stderr || data.stdout || '';
+
+ if (!text.includes('Traceback') && !text.includes('Exception')) {
+ return 'No stack trace information available
';
+ }
+
+ // extract traceback sections
+ const tracebackPattern = /Traceback \(most recent call last\):[\s\S]*?(?=\n\n|\n[A-Z]|\n$|$)/g;
+ const tracebacks = text.match(tracebackPattern) || [];
+
+ if (tracebacks.length === 0) {
+ return 'No stack trace information available
';
+ }
+
+ return `
+
+
+ ${tracebacks.map((trace, index) => `
+
+
Traceback ${index + 1}
+
${formatStackTraceText(trace)}
+
+ `).join('')}
+
+ `;
+}
+
+// format individual stack trace with enhanced readability
+function formatStackTraceText(text) {
+ let result = escapeHtml(text);
+
+ result = result
+ // highlight file paths and line numbers
+ .replace(/(File\s+)"([^"]+)",\s+line\s+(\d+)/g,
+ '$1"$2 ", line $3 ')
+
+ // highlight function names
+ .replace(/in\s+([a-zA-Z_][a-zA-Z0-9_]*)/g, 'in $1 ')
+
+ // highlight exception types
+ .replace(/^([A-Z][a-zA-Z]*Error|[A-Z][a-zA-Z]*Exception):/gm,
+ '$1 :')
+
+ // highlight traceback header
+ .replace(/(Traceback \(most recent call last\):)/, '');
+
+ return result;
+}
+
+// format python performance metrics
+function formatPythonPerformance(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+
+ // extract timing information
+ const timingPatterns = [
+ /executed in (\d+\.?\d*)\s*(s|ms|seconds?|milliseconds?)/gi,
+ /took (\d+\.?\d*)\s*(s|ms|seconds?|milliseconds?)/gi,
+ /duration[:\s]+(\d+\.?\d*)\s*(s|ms|seconds?|milliseconds?)/gi,
+ /time[:\s]+(\d+\.?\d*)\s*(s|ms|seconds?|milliseconds?)/gi
+ ];
+
+ let timings = [];
+ timingPatterns.forEach(pattern => {
+ let match;
+ while ((match = pattern.exec(text)) !== null) {
+ timings.push({
+ value: parseFloat(match[1]),
+ unit: match[2],
+ context: text.substring(Math.max(0, match.index - 50), match.index + 100)
+ });
+ }
+ });
+
+ if (timings.length === 0 && !data.elapsed_time) {
+ return 'No performance information available
';
+ }
+
+ return `
+
+ `;
+}
+
+// format python environment information
+function formatPythonEnvironment(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+
+ // extract import statements and module information
+ const imports = extractPythonImports(text);
+ const modules = extractPythonModules(text);
+
+ return `
+
+
+
+ ${data.start_time ? `
+
+
+
+
+ Started:
+ ${new Date(data.start_time).toLocaleString()}
+
+ ${data.end_time ? `
+
+ Completed:
+ ${new Date(data.end_time).toLocaleString()}
+
+ ` : ''}
+
+
+ ` : ''}
+
+ ${imports.length > 0 ? `
+
+
+
+ ${imports.map(imp => `
+ ${imp}
+ `).join('')}
+
+
+ ` : ''}
+
+ ${modules.length > 0 ? `
+
+
+
+ ${modules.map(module => `
+
+ ${module.name}
+ ${module.version ? `${module.version} ` : ''}
+
+ `).join('')}
+
+
+ ` : ''}
+
+ `;
+}
+
+// extract python imports from output
+function extractPythonImports(text) {
+ const importPatterns = [
+ /^import\s+([a-zA-Z_][a-zA-Z0-9_.]*)/gm,
+ /^from\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s+import/gm
+ ];
+
+ let imports = new Set();
+
+ importPatterns.forEach(pattern => {
+ let match;
+ while ((match = pattern.exec(text)) !== null) {
+ imports.add(match[1]);
+ }
+ });
+
+ return Array.from(imports).slice(0, 20); // limit to 20 imports
+}
+
+// extract python module versions from output
+function extractPythonModules(text) {
+ const modulePattern = /([a-zA-Z_][a-zA-Z0-9_-]+)[:\s]+(\d+\.\d+(?:\.\d+)?)/g;
+ let modules = [];
+ let match;
+
+ while ((match = modulePattern.exec(text)) !== null) {
+ modules.push({
+ name: match[1],
+ version: match[2]
+ });
+ }
+
+ return modules.slice(0, 10); // limit to 10 modules
+}
+
+// switch between python output tabs
+function switchPythonTab(tabId, button) {
+ const container = button.closest('.python-tabs');
+
+ const contents = container.querySelectorAll('.python-tab-content');
+ contents.forEach(content => {
+ content.classList.remove('active');
+ });
+
+ const buttons = container.querySelectorAll('.tab-button');
+ buttons.forEach(btn => {
+ btn.classList.remove('active');
+ });
+
+ const targetTab = document.getElementById(tabId);
+ if (targetTab) {
+ targetTab.classList.add('active');
+ }
+
+ button.classList.add('active');
+}
+
+// ansible playbook output formatting functions
+function formatAnsibleOutput(executionData) {
+ const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData;
+ const tabPrefix = 'ansible-' + Date.now();
+
+ return `
+
+
+ Play Summary
+ Task Details
+ Host Results
+ Variables
+ Console Log
+ Raw Data
+
+
+
+ ${formatAnsibleSummary(data)}
+
+
+
+ ${formatAnsibleTasks(data)}
+
+
+
+ ${formatAnsibleHosts(data)}
+
+
+
+ ${formatAnsibleVariables(data)}
+
+
+
+ ${formatAnsibleConsole(data)}
+
+
+
+
${escapeHtml(JSON.stringify(data, null, 2))}
+
+
+ `;
+}
+
+// format ansible play summary
+function formatAnsibleSummary(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+
+ // extract play and task summary information
+ const playResults = extractAnsiblePlayResults(text);
+ const hostSummary = extractAnsibleHostSummary(text);
+
+ return `
+
+
+
+
+
+
+ Total Plays:
+ ${playResults.length}
+
+
+ Hosts:
+ ${Object.keys(hostSummary).length}
+
+
+ Duration:
+ ${data.elapsed_time ? data.elapsed_time.toFixed(2) + 's' : 'N/A'}
+
+
+ Result:
+ ${data.return_code === 0 ? 'SUCCESS' : 'FAILED'}
+
+
+
+
+ ${Object.keys(hostSummary).length > 0 ? `
+
+
+
+ ${Object.entries(hostSummary).map(([host, stats]) => `
+
+
${host}
+
+ ${stats.ok || 0} ok
+ ${stats.changed || 0} changed
+ ${stats.failed || 0} failed
+ ${stats.skipped || 0} skipped
+
+
+ `).join('')}
+
+
+ ` : ''}
+
+ ${playResults.length > 0 ? `
+
+
+
+ ${playResults.map((play, index) => `
+
+
+
+ Tasks: ${play.tasks || 0}
+ Hosts: ${play.hosts || 0}
+
+
+ `).join('')}
+
+
+ ` : ''}
+
+ `;
+}
+
+// format ansible task details
+function formatAnsibleTasks(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+ const tasks = extractAnsibleTasks(text);
+
+ if (tasks.length === 0) {
+ return 'No task information available
';
+ }
+
+ return `
+
+
+
+
+ ${tasks.map((task, index) => `
+
+
+
+
+
+
${task.module || 'unknown'}
+ ${task.duration ? `
${task.duration}
` : ''}
+
+ ${task.hosts && task.hosts.length > 0 ? `
+
+ Affected hosts:
+ ${task.hosts.map(host => `${host} `).join('')}
+
+ ` : ''}
+ ${task.changes && task.changes.length > 0 ? `
+
+
+
${escapeHtml(task.changes.join('\n'))}
+
+ ` : ''}
+
+
+ `).join('')}
+
+
+ `;
+}
+
+// format ansible host results
+function formatAnsibleHosts(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+ const hostResults = extractAnsibleHostResults(text);
+
+ if (Object.keys(hostResults).length === 0) {
+ return 'No host results available
';
+ }
+
+ return `
+
+
+
+
+ ${Object.entries(hostResults).map(([host, result]) => `
+
+
+
+
+
+ OK:
+ ${result.ok || 0}
+
+
+ Changed:
+ ${result.changed || 0}
+
+
+ Failed:
+ ${result.failed || 0}
+
+
+ Skipped:
+ ${result.skipped || 0}
+
+
+ Unreachable:
+ ${result.unreachable || 0}
+
+
+
+ ${result.tasks && result.tasks.length > 0 ? `
+
+
+
+ ${result.tasks.slice(0, 5).map(task => `
+
+
+ ${task.name}
+
+ `).join('')}
+ ${result.tasks.length > 5 ? `
+${result.tasks.length - 5} more tasks
` : ''}
+
+
+ ` : ''}
+
+ `).join('')}
+
+
+ `;
+}
+
+// format ansible variables
+function formatAnsibleVariables(data) {
+ const text = (data.stdout || '') + (data.stderr || '');
+ const variables = extractAnsibleVariables(text);
+
+ if (Object.keys(variables).length === 0) {
+ return 'No variable information available
';
+ }
+
+ return `
+
+
+
+
+ ${Object.entries(variables).map(([category, vars]) => `
+
+
${category}
+
+ ${Object.entries(vars).slice(0, 10).map(([key, value]) => `
+
+
${key}
+
+ ${typeof value === 'object' ?
+ `
${escapeHtml(JSON.stringify(value, null, 2))} ` :
+ escapeHtml(String(value))
+ }
+
+
+ `).join('')}
+ ${Object.keys(vars).length > 10 ? `
+${Object.keys(vars).length - 10} more variables
` : ''}
+
+
+ `).join('')}
+
+
+ `;
+}
+
+// format ansible console output
+function formatAnsibleConsole(data) {
+ if (!data.stdout && !data.stderr) {
+ return 'No console output available
';
+ }
+
+ let html = '';
+
+ if (data.stdout) {
+ const formattedStdout = formatAnsibleLogOutput(data.stdout);
+ html += `
+
+ `;
+ }
+
+ if (data.stderr) {
+ const formattedStderr = formatAnsibleLogOutput(data.stderr);
+ html += `
+
+ `;
+ }
+
+ return html;
+}
+
+// format ansible log output with color coding
+function formatAnsibleLogOutput(text) {
+ if (!text) return '';
+
+ let result = escapeHtml(text);
+
+ // ansible status patterns with colors
+ result = result
+ .replace(/\b(PLAY RECAP)\b/g, '$1 ')
+ .replace(/\b(TASK|PLAY)\s*\[(.*?)\]/g, '$1 [$2 ]')
+ .replace(/\b(ok|OK)\b/g, '$1 ')
+ .replace(/\b(changed|CHANGED)\b/g, '$1 ')
+ .replace(/\b(failed|FAILED|fatal)\b/g, '$1 ')
+ .replace(/\b(skipping|skipped|SKIPPED)\b/g, '$1 ')
+ .replace(/\b(unreachable|UNREACHABLE)\b/g, '$1 ')
+
+ // highlight ansible host references
+ .replace(/(\w+\.[\w.-]+|\d+\.\d+\.\d+\.\d+)\s*:/g, '$1 :')
+
+ // highlight timing
+ .replace(/(\d+\.?\d*)\s*(s|sec|seconds?)\b/gi, '$1$2 ')
+
+ // highlight json/yaml content
+ .replace(/(\{[^{}]*\})/g, '$1 ')
+ .replace(/(---|\.\.\.|^[\s]*[-\w]+:)/gm, '$1 ');
+
+ return result;
+}
+
+// extraction functions for ansible data
+function extractAnsiblePlayResults(text) {
+ const playPattern = /PLAY \[(.*?)\]/g;
+ let plays = [];
+ let match;
+
+ while ((match = playPattern.exec(text)) !== null) {
+ plays.push({
+ name: match[1],
+ status: text.includes('fatal:') ? 'failed' : 'ok',
+ tasks: 0,
+ hosts: 0
+ });
+ }
+
+ return plays;
+}
+
+function extractAnsibleHostSummary(text) {
+ const recapPattern = /PLAY RECAP[\s\S]*?(?=\n\n|$)/;
+ const match = text.match(recapPattern);
+
+ if (!match) return {};
+
+ const recapText = match[0];
+ const hostPattern = /(\S+)\s+:\s+ok=(\d+)\s+changed=(\d+)(?:\s+unreachable=(\d+))?\s+failed=(\d+)(?:\s+skipped=(\d+))?/g;
+ let hosts = {};
+ let hostMatch;
+
+ while ((hostMatch = hostPattern.exec(recapText)) !== null) {
+ hosts[hostMatch[1]] = {
+ ok: parseInt(hostMatch[2]),
+ changed: parseInt(hostMatch[3]),
+ unreachable: parseInt(hostMatch[4] || 0),
+ failed: parseInt(hostMatch[5]),
+ skipped: parseInt(hostMatch[6] || 0)
+ };
+ }
+
+ return hosts;
+}
+
+function extractAnsibleTasks(text) {
+ const taskPattern = /TASK \[(.*?)\][\s\S]*?(?=TASK \[|PLAY \[|PLAY RECAP|$)/g;
+ let tasks = [];
+ let match;
+
+ while ((match = taskPattern.exec(text)) !== null) {
+ const taskText = match[0];
+ const name = match[1];
+
+ tasks.push({
+ name: name,
+ status: taskText.includes('failed:') ? 'failed' :
+ taskText.includes('changed:') ? 'changed' :
+ taskText.includes('skipping:') ? 'skipped' : 'ok',
+ module: extractModuleName(taskText),
+ hosts: extractTaskHosts(taskText),
+ changes: extractTaskChanges(taskText)
+ });
+ }
+
+ return tasks;
+}
+
+function extractAnsibleHostResults(text) {
+ const hostSummary = extractAnsibleHostSummary(text);
+ const tasks = extractAnsibleTasks(text);
+
+ let hostResults = {};
+
+ Object.entries(hostSummary).forEach(([host, stats]) => {
+ hostResults[host] = {
+ ...stats,
+ overall_status: stats.failed > 0 ? 'failed' :
+ stats.changed > 0 ? 'changed' : 'ok',
+ tasks: tasks.filter(task => task.hosts.includes(host))
+ };
+ });
+
+ return hostResults;
+}
+
+function extractAnsibleVariables(text) {
+ // basic variable extraction - could be enhanced based on actual ansible output format
+ return {
+ 'Facts': extractAnsibleFacts(text),
+ 'Variables': extractAnsibleVars(text)
+ };
+}
+
+function extractModuleName(taskText) {
+ const moduleMatch = taskText.match(/(\w+):\s*\{/);
+ return moduleMatch ? moduleMatch[1] : 'unknown';
+}
+
+function extractTaskHosts(taskText) {
+ const hostMatches = taskText.match(/(\w+[\w.-]*)\s*:/g);
+ return hostMatches ? hostMatches.map(h => h.replace(':', '')) : [];
+}
+
+function extractTaskChanges(taskText) {
+ const changePattern = /changed:\s*\[(.*?)\]/g;
+ let changes = [];
+ let match;
+
+ while ((match = changePattern.exec(taskText)) !== null) {
+ changes.push(match[1]);
+ }
+
+ return changes;
+}
+
+function extractAnsibleFacts(text) {
+ // extract common ansible facts from output
+ const facts = {};
+ const factPatterns = {
+ 'ansible_os_family': /ansible_os_family['"]\s*:\s*['"]([^'"]+)['"]/,
+ 'ansible_distribution': /ansible_distribution['"]\s*:\s*['"]([^'"]+)['"]/,
+ 'ansible_python_version': /ansible_python_version['"]\s*:\s*['"]([^'"]+)['"]/
+ };
+
+ Object.entries(factPatterns).forEach(([key, pattern]) => {
+ const match = text.match(pattern);
+ if (match) facts[key] = match[1];
+ });
+
+ return facts;
+}
+
+function extractAnsibleVars(text) {
+ // extract ansible variables from output
+ const vars = {};
+ const varPattern = /(\w+)\s*:\s*([^,}\n]+)/g;
+ let match;
+
+ while ((match = varPattern.exec(text)) !== null && Object.keys(vars).length < 10) {
+ if (!match[1].startsWith('ansible_')) {
+ vars[match[1]] = match[2].trim();
+ }
+ }
+
+ return vars;
+}
+
+// switch between ansible output tabs
+function switchAnsibleTab(tabId, button) {
+ const container = button.closest('.ansible-tabs');
+
+ const contents = container.querySelectorAll('.ansible-tab-content');
+ contents.forEach(content => {
+ content.classList.remove('active');
+ });
+
+ const buttons = container.querySelectorAll('.tab-button');
+ buttons.forEach(btn => {
+ btn.classList.remove('active');
+ });
+
+ const targetTab = document.getElementById(tabId);
+ if (targetTab) {
+ targetTab.classList.add('active');
+ }
+
+ button.classList.add('active');
+}
+
// close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('execution-modal');