Airflow Task Instance Status Enhancer

Enhance task instance status visualization in Airflow for colorblind users with class transition tracking

  1. // ==UserScript==
  2. // @name Airflow Task Instance Status Enhancer
  3. // @namespace namilink.airflow.colorblind-status
  4. // @version 0.6
  5. // @description Enhance task instance status visualization in Airflow for colorblind users with class transition tracking
  6. // @author Mate Valko - Namilink.com
  7. // @match *://*/*dags*
  8. // @match *://*/*airflow*
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // Configuration
  16. const CONFIG = {
  17. DEBUG: false,
  18. THROTTLE_INTERVAL: 1000,
  19. TASK_INSTANCE_SELECTOR: '[data-testid="task-instance"]'
  20. };
  21.  
  22. const STATE_MAPPINGS = {
  23. 'rgb(128, 128, 128)': { symbol: '⌛', label: 'Queued' }, // gray
  24. 'rgb(0, 255, 0)': { symbol: '⚙️', label: 'Running' }, // lime
  25. 'rgb(0, 128, 0)': { symbol: '✅', label: 'Success' }, // green
  26. 'rgb(238, 130, 238)': { symbol: '🔄', label: 'Restarting' }, // violet
  27. 'rgb(255, 0, 0)': { symbol: '❌', label: 'Failed' }, // red
  28. 'rgb(255, 215, 0)': { symbol: '🔁', label: 'Up for retry' }, // gold
  29. 'rgb(64, 224, 208)': { symbol: '⏳', label: 'Reschedule' }, // turquoise
  30. 'rgb(255, 165, 0)': { symbol: '⚠️', label: 'Upstream failed' }, // orange
  31. 'rgb(255, 105, 180)': { symbol: '⤵️', label: 'Skipped' }, // hotpink
  32. 'rgb(211, 211, 211)': { symbol: '🗑️', label: 'Removed' }, // lightgrey
  33. 'rgb(210, 180, 140)': { symbol: '⏰', label: 'Scheduled' }, // tan
  34. 'rgb(147, 112, 219)': { symbol: '⏸️', label: 'Deferred' } // mediumpurple
  35. };
  36.  
  37.  
  38. const ClassTransitionStore = {
  39. classToState: new Map(),
  40.  
  41. updateMapping(className, color) {
  42. if (!this.classToState.has(className)) {
  43. const state = STATE_MAPPINGS[color];
  44. if (state) {
  45. this.classToState.set(className, state);
  46. debugLog('New class mapping:', className, state, color);
  47. }
  48. }
  49. return this.classToState.get(className);
  50. },
  51.  
  52. getStateForClass(className) {
  53. return this.classToState.get(className);
  54. },
  55.  
  56. debug() {
  57. console.log('Class to State Mappings:',
  58. Object.fromEntries(this.classToState));
  59. }
  60. };
  61.  
  62. let lastExecutionTime = 0;
  63.  
  64. function debugLog(...args) {
  65. if (CONFIG.DEBUG) console.log('[AirflowEnhancer]', ...args);
  66. }
  67.  
  68. function createStatusIndicator(state) {
  69. const container = document.createElement('div');
  70. container.innerHTML = `<div class="status-symbol">${state.symbol}</div>`;
  71. container.style.cssText = `
  72. display: flex;
  73. flex-direction: column;
  74. align-items: center;
  75. justify-content: center;
  76. width: 100%;
  77. height: 100%;
  78. font-size: 14px;
  79. font-weight: bold;
  80. `;
  81. return container;
  82. }
  83.  
  84. function findElementsInShadowDOM(root, selector) {
  85. const elements = new Set();
  86.  
  87. function traverse(node) {
  88. if (!node) return;
  89. if (node.matches && node.matches(selector)) {
  90. elements.add(node);
  91. }
  92. if (node.shadowRoot) {
  93. Array.from(node.shadowRoot.querySelectorAll('*')).forEach(traverse);
  94. }
  95. if (node.children) {
  96. Array.from(node.children).forEach(traverse);
  97. }
  98. }
  99.  
  100. traverse(root);
  101. return Array.from(elements);
  102. }
  103.  
  104. function modifyElement(element) {
  105. if (!element?.isConnected) return;
  106.  
  107. const reactClass = Array.from(element.classList)
  108. .find(cls => cls.startsWith('c-'));
  109.  
  110. if (!reactClass) {
  111. debugLog('No React class found for element');
  112. return;
  113. }
  114.  
  115. let state = ClassTransitionStore.getStateForClass(reactClass);
  116.  
  117. if (!state) {
  118. const backgroundColor = window.getComputedStyle(element).backgroundColor;
  119. state = ClassTransitionStore.updateMapping(reactClass, backgroundColor);
  120.  
  121. if (!state) {
  122. if (backgroundColor !== '' && backgroundColor !== 'inherit' && backgroundColor !== 'transparent') {
  123. debugLog('Unable to map new class:', reactClass, 'with color:', backgroundColor);
  124. }
  125. return;
  126. }
  127. }
  128.  
  129. debugLog('Applying state:', { class: reactClass, state });
  130. element.style.setProperty('background', 'none', 'important');
  131. element.innerHTML = '';
  132. element.appendChild(createStatusIndicator(state));
  133. }
  134.  
  135. async function modifyElements() {
  136. const rootElements = document.querySelectorAll('#root, #react-container, [id*="react"]');
  137. const taskInstances = new Set();
  138.  
  139. [...rootElements, document.body].forEach(root => {
  140. findElementsInShadowDOM(root, CONFIG.TASK_INSTANCE_SELECTOR)
  141. .forEach(element => taskInstances.add(element));
  142. });
  143.  
  144. if (taskInstances.size === 0) {
  145. debugLog('No task instances found, retrying...');
  146. await new Promise(resolve => setTimeout(resolve, 300));
  147. return modifyElements();
  148. }
  149.  
  150. debugLog(`Found ${taskInstances.size} task instances`);
  151. taskInstances.forEach(modifyElement);
  152. }
  153.  
  154. function throttledModifyElements() {
  155. const now = Date.now();
  156. if (now - lastExecutionTime >= CONFIG.THROTTLE_INTERVAL) {
  157. lastExecutionTime = now;
  158. modifyElements();
  159. }
  160. }
  161.  
  162. function initialize() {
  163. debugLog('Initializing script');
  164. window._airflowEnhancerStore = window._airflowEnhancerStore || ClassTransitionStore;
  165.  
  166. modifyElements();
  167.  
  168. // Create and configure MutationObserver
  169. const observer = new MutationObserver((mutations) => {
  170. mutations.forEach(mutation => {
  171. if (mutation.addedNodes.length ||
  172. (mutation.type === 'attributes' &&
  173. (mutation.attributeName === 'style' ||
  174. mutation.attributeName === 'class'))) {
  175. throttledModifyElements();
  176. }
  177. });
  178. });
  179.  
  180. // Start observing the document
  181. observer.observe(document.body, {
  182. childList: true,
  183. subtree: true,
  184. attributes: true,
  185. attributeFilter: ['style', 'class']
  186. });
  187.  
  188. // Periodic debug output
  189. if (CONFIG.DEBUG) {
  190. setInterval(() => {
  191. ClassTransitionStore.debug();
  192. }, 10000);
  193. }
  194.  
  195. // Event listeners for dynamic content
  196. ['load', 'urlchange'].forEach(event =>
  197. window.addEventListener(event, throttledModifyElements));
  198.  
  199. // Handle history state changes
  200. ['pushState', 'replaceState'].forEach(method => {
  201. const original = history[method];
  202. history[method] = function() {
  203. original.apply(history, arguments);
  204. throttledModifyElements();
  205. };
  206. });
  207.  
  208. window.addEventListener('popstate', throttledModifyElements);
  209.  
  210. // Cleanup on page unload
  211. window.addEventListener('unload', () => {
  212. observer.disconnect();
  213. });
  214. }
  215.  
  216. // Start the script
  217. if (document.readyState === 'loading') {
  218. document.addEventListener('DOMContentLoaded', initialize);
  219. } else {
  220. initialize();
  221. }
  222.  
  223. })();