Spaces:
Running
Running
| <html lang="en" class="h-full"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>dstack Configuration Editor</title> | |
| <!-- Tailwind CSS CDN --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Inter Font --> | |
| <link rel="stylesheet" href="https://rsms.me/inter/inter.css"> | |
| <!-- JS-YAML CDN --> | |
| <script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script> | |
| <!-- CodeMirror for YAML syntax highlighting --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script> | |
| <style> | |
| /* Modern styling with animations and gradients */ | |
| html { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| scroll-behavior: smooth; | |
| } | |
| @supports (font-variation-settings: normal) { | |
| html { font-family: 'Inter var', system-ui, -apple-system, sans-serif; } | |
| } | |
| body { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); | |
| background-attachment: fixed; | |
| color: #1e293b; | |
| min-height: 100vh; | |
| position: relative; | |
| } | |
| /* Enhanced animated background with multiple layers */ | |
| body::after { | |
| content: ''; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: | |
| radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%), | |
| radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%), | |
| radial-gradient(circle at 40% 40%, rgba(120, 200, 255, 0.2) 0%, transparent 50%); | |
| pointer-events: none; | |
| z-index: -1; | |
| animation: float 20s ease-in-out infinite; | |
| } | |
| /* Animated background pattern */ | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); | |
| pointer-events: none; | |
| z-index: -1; | |
| } | |
| /* Beautiful custom scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 12px; | |
| height: 12px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: rgba(148, 163, 184, 0.1); | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border-radius: 10px; | |
| border: 2px solid transparent; | |
| background-clip: content-box; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: linear-gradient(135deg, #4f46e5, #7c3aed); | |
| background-clip: content-box; | |
| } | |
| /* Form container bold text */ | |
| #form-container { | |
| font-weight: bold; | |
| } | |
| /* YAML editor container overflow */ | |
| #yaml-editor-container { | |
| overflow: auto; | |
| } | |
| /* Glass with lens distortion */ | |
| .glass { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.12) 0%, | |
| rgba(255, 255, 255, 0.06) 100%); | |
| backdrop-filter: blur(16px) saturate(180%) contrast(120%); | |
| -webkit-backdrop-filter: blur(16px) saturate(180%) contrast(120%); | |
| border: 1px solid rgba(255, 255, 255, 0.25); | |
| box-shadow: | |
| 0 8px 32px rgba(31, 38, 135, 0.37), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.5), | |
| inset 0 -1px 0 rgba(255, 255, 255, 0.1); | |
| position: relative; | |
| overflow: hidden; | |
| filter: contrast(1.1) brightness(1.05); | |
| } | |
| .glass-dark { | |
| background: linear-gradient(135deg, | |
| rgba(15, 23, 42, 0.75) 0%, | |
| rgba(15, 23, 42, 0.55) 100%); | |
| backdrop-filter: blur(20px) saturate(160%) contrast(110%); | |
| -webkit-backdrop-filter: blur(20px) saturate(160%) contrast(110%); | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| box-shadow: | |
| 0 8px 32px rgba(0, 0, 0, 0.3), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.3), | |
| inset 0 -1px 0 rgba(255, 255, 255, 0.1); | |
| position: relative; | |
| overflow: hidden; | |
| filter: contrast(1.08) brightness(1.02); | |
| } | |
| /* Lens magnification for cards */ | |
| .card { | |
| box-shadow: | |
| 0 20px 60px rgba(31, 38, 135, 0.4), | |
| 0 10px 30px rgba(31, 38, 135, 0.3), | |
| 0 5px 15px rgba(31, 38, 135, 0.2); | |
| transform: translateZ(0) scale(1); | |
| filter: contrast(1.05) saturate(110%); | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .card:hover { | |
| transform: translateZ(10px) translateY(-5px) scale(1.03); | |
| filter: contrast(1.1) saturate(120%) brightness(1.05); | |
| box-shadow: | |
| 0 30px 80px rgba(31, 38, 135, 0.5), | |
| 0 20px 50px rgba(31, 38, 135, 0.4), | |
| 0 10px 25px rgba(31, 38, 135, 0.3); | |
| } | |
| /* Lens focus buttons */ | |
| .btn-primary, .btn-secondary { | |
| box-shadow: | |
| 0 8px 25px rgba(99, 102, 241, 0.4), | |
| 0 4px 15px rgba(99, 102, 241, 0.3), | |
| 0 2px 8px rgba(99, 102, 241, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.3); | |
| transform: translateZ(0) scale(1); | |
| filter: contrast(1.1) saturate(120%); | |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .btn-primary:hover, .btn-secondary:hover { | |
| transform: translateZ(5px) translateY(-2px) scale(1.05); | |
| filter: contrast(1.2) saturate(140%) brightness(1.1); | |
| box-shadow: | |
| 0 12px 35px rgba(99, 102, 241, 0.5), | |
| 0 6px 20px rgba(99, 102, 241, 0.4), | |
| 0 3px 12px rgba(99, 102, 241, 0.3), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.4); | |
| } | |
| /* Lens refraction inputs */ | |
| .form-input, .form-select { | |
| box-shadow: | |
| inset 0 3px 8px rgba(0, 0, 0, 0.1), | |
| inset 0 1px 4px rgba(0, 0, 0, 0.08), | |
| 0 2px 6px rgba(255, 255, 255, 0.1); | |
| filter: contrast(1.05) saturate(105%); | |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .form-input:focus, .form-select:focus { | |
| box-shadow: | |
| inset 0 3px 8px rgba(0, 0, 0, 0.15), | |
| inset 0 1px 4px rgba(0, 0, 0, 0.1), | |
| 0 4px 12px rgba(99, 102, 241, 0.2), | |
| 0 0 0 2px rgba(99, 102, 241, 0.3); | |
| filter: contrast(1.1) saturate(115%) brightness(1.05); | |
| transform: scale(1.01); | |
| } | |
| /* Glowing effects */ | |
| .glow { | |
| box-shadow: 0 0 20px rgba(99, 102, 241, 0.3); | |
| } | |
| .glow:hover { | |
| box-shadow: 0 0 30px rgba(99, 102, 241, 0.5); | |
| transform: translateY(-2px); | |
| } | |
| /* Modern button styles */ | |
| .btn-primary { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border: none; | |
| box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4); | |
| } | |
| .btn-primary:hover { | |
| background: linear-gradient(135deg, #4f46e5, #7c3aed); | |
| box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6); | |
| transform: translateY(-1px); | |
| } | |
| .btn-secondary { | |
| background: rgba(255, 255, 255, 0.2); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| color: white; | |
| } | |
| .btn-secondary:hover { | |
| background: rgba(255, 255, 255, 0.3); | |
| transform: translateY(-1px); | |
| } | |
| /* Enhanced form input with better readability */ | |
| .form-input { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.15) 0%, | |
| rgba(255, 255, 255, 0.1) 100%); | |
| border: 2px solid rgba(255, 255, 255, 0.4); | |
| backdrop-filter: blur(10px) saturate(180%); | |
| -webkit-backdrop-filter: blur(10px) saturate(180%); | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| color: white; | |
| font-weight: 500; | |
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); | |
| box-shadow: | |
| 0 4px 16px rgba(31, 38, 135, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.4); | |
| position: relative; | |
| } | |
| /* Annotation styles */ | |
| .annotation-container { | |
| position: relative; | |
| } | |
| .annotation-btn { | |
| position: absolute; | |
| right: 8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| z-index: 10; | |
| background: rgba(99, 102, 241, 0.2); | |
| border: 1px solid rgba(99, 102, 241, 0.4); | |
| border-radius: 6px; | |
| padding: 4px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .annotation-btn:hover { | |
| background: rgba(99, 102, 241, 0.3); | |
| border-color: rgba(99, 102, 241, 0.6); | |
| } | |
| .annotation-btn.has-annotation { | |
| background: rgba(34, 197, 94, 0.2); | |
| border-color: rgba(34, 197, 94, 0.4); | |
| } | |
| .annotation-popup { | |
| position: absolute; | |
| top: 100%; | |
| right: 0; | |
| margin-top: 8px; | |
| width: 300px; | |
| background: linear-gradient(135deg, | |
| rgba(15, 23, 42, 0.95) 0%, | |
| rgba(15, 23, 42, 0.9) 100%); | |
| backdrop-filter: blur(20px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-radius: 12px; | |
| padding: 16px; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); | |
| z-index: 20; | |
| display: none; | |
| } | |
| .annotation-popup.show { | |
| display: block; | |
| animation: slideInUp 0.2s ease-out; | |
| } | |
| .annotation-input { | |
| width: 100%; | |
| background: rgba(255, 255, 255, 0.1); | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| color: white; | |
| font-size: 0.875rem; | |
| resize: vertical; | |
| min-height: 60px; | |
| } | |
| .annotation-input:focus { | |
| outline: none; | |
| border-color: rgba(99, 102, 241, 0.6); | |
| box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); | |
| } | |
| .annotation-actions { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 12px; | |
| } | |
| .annotation-save, .annotation-cancel { | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .annotation-save { | |
| background: rgba(34, 197, 94, 0.2); | |
| border: 1px solid rgba(34, 197, 94, 0.4); | |
| color: rgb(34, 197, 94); | |
| } | |
| .annotation-save:hover { | |
| background: rgba(34, 197, 94, 0.3); | |
| } | |
| .annotation-cancel { | |
| background: rgba(239, 68, 68, 0.2); | |
| border: 1px solid rgba(239, 68, 68, 0.4); | |
| color: rgb(239, 68, 68); | |
| } | |
| .annotation-cancel:hover { | |
| background: rgba(239, 68, 68, 0.3); | |
| } | |
| .annotation-display { | |
| margin-top: 8px; | |
| padding: 8px 12px; | |
| background: rgba(34, 197, 94, 0.1); | |
| border: 1px solid rgba(34, 197, 94, 0.2); | |
| border-radius: 8px; | |
| font-size: 0.75rem; | |
| color: rgba(255, 255, 255, 0.8); | |
| line-height: 1.4; | |
| } | |
| .form-input:focus { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.25) 0%, | |
| rgba(255, 255, 255, 0.18) 100%); | |
| border-color: rgba(255, 255, 255, 0.6); | |
| box-shadow: | |
| 0 0 0 2px rgba(255, 255, 255, 0.1), | |
| 0 4px 16px rgba(31, 38, 135, 0.3), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| transform: translateY(-1px); | |
| text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); | |
| outline: none; | |
| } | |
| /* Enhanced dropdown with better readability */ | |
| .form-select { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.15) 0%, | |
| rgba(255, 255, 255, 0.1) 100%); | |
| border: 2px solid rgba(255, 255, 255, 0.4); | |
| backdrop-filter: blur(10px) saturate(180%); | |
| -webkit-backdrop-filter: blur(10px) saturate(180%); | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| color: white; | |
| font-weight: 500; | |
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); | |
| box-shadow: | |
| 0 4px 16px rgba(31, 38, 135, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.4); | |
| } | |
| .form-select:focus { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.25) 0%, | |
| rgba(255, 255, 255, 0.18) 100%); | |
| border-color: rgba(255, 255, 255, 0.6); | |
| box-shadow: | |
| 0 0 0 2px rgba(255, 255, 255, 0.1), | |
| 0 4px 16px rgba(31, 38, 135, 0.3), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| transform: translateY(-1px); | |
| text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); | |
| outline: none; | |
| } | |
| .form-select option { | |
| background: rgba(15, 23, 42, 0.95); | |
| color: white; | |
| padding: 8px 12px; | |
| } | |
| .form-select option:checked { | |
| background: rgba(99, 102, 241, 0.8); | |
| } | |
| .form-select option:hover { | |
| background: rgba(99, 102, 241, 0.6); | |
| } | |
| /* Remove default backgrounds from grouped components */ | |
| fieldset { | |
| background-color: transparent ; | |
| background: none ; | |
| } | |
| /* Override glass background for fieldsets - they should be transparent containers */ | |
| fieldset.glass { | |
| background: transparent ; | |
| background-color: transparent ; | |
| backdrop-filter: none ; | |
| -webkit-backdrop-filter: none ; | |
| box-shadow: none ; | |
| border: 1px solid rgba(255, 255, 255, 0.2) ; | |
| } | |
| /* Keep fieldsets completely transparent even on hover */ | |
| fieldset.glass:hover { | |
| background: transparent ; | |
| backdrop-filter: none ; | |
| -webkit-backdrop-filter: none ; | |
| } | |
| /* Ensure legends have proper glass backgrounds */ | |
| fieldset legend { | |
| background-color: transparent; | |
| } | |
| /* Global text readability improvements */ | |
| body, * { | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| /* Better text contrast for all white text */ | |
| .text-white { | |
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | |
| } | |
| /* Enhanced placeholder text */ | |
| ::placeholder { | |
| color: rgba(255, 255, 255, 0.6); | |
| text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); | |
| } | |
| /* Simple, fast collapsible */ | |
| .fieldset-content { | |
| overflow: hidden; | |
| } | |
| .fieldset-content.expanded { | |
| max-height: 500px; | |
| overflow-y: auto; | |
| display: block; | |
| } | |
| .fieldset-content.collapsed { | |
| max-height: 0; | |
| overflow: hidden; | |
| display: none; | |
| } | |
| /* Custom scrollbar for fieldset content */ | |
| .fieldset-content::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .fieldset-content::-webkit-scrollbar-track { | |
| background: rgba(148, 163, 184, 0.1); | |
| border-radius: 3px; | |
| } | |
| .fieldset-content::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border-radius: 3px; | |
| } | |
| .fieldset-content::-webkit-scrollbar-thumb:hover { | |
| background: linear-gradient(135deg, #4f46e5, #7c3aed); | |
| } | |
| /* Arrow rotation - no animation */ | |
| .collapse-arrow { | |
| /* No transition for instant feedback */ | |
| } | |
| .collapse-arrow.collapsed { | |
| transform: rotate(-90deg); | |
| } | |
| .collapse-arrow.expanded { | |
| transform: rotate(0deg); | |
| } | |
| /* Fieldset entrance animation */ | |
| @keyframes slideInUp { | |
| from { | |
| opacity: 0; | |
| transform: translateY(20px) scale(0.95); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| .fieldset-enter { | |
| animation: slideInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| /* Legend hover animation */ | |
| .legend-hover { | |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| /* Preset buttons styling */ | |
| .glass-preset { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.1) 0%, | |
| rgba(255, 255, 255, 0.05) 100%); | |
| backdrop-filter: blur(10px) saturate(180%); | |
| -webkit-backdrop-filter: blur(10px) saturate(180%); | |
| box-shadow: | |
| 0 4px 16px rgba(31, 38, 135, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.3); | |
| } | |
| .glass-preset:hover { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.15) 0%, | |
| rgba(255, 255, 255, 0.08) 100%); | |
| box-shadow: | |
| 0 6px 20px rgba(31, 38, 135, 0.3), | |
| 0 0 0 2px rgba(255, 255, 255, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.4); | |
| transform: translateY(-2px) scale(1.05); | |
| } | |
| .glass-preset:active { | |
| transform: translateY(0) scale(1.02); | |
| box-shadow: | |
| 0 2px 8px rgba(31, 38, 135, 0.4), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| /* Summary preview styling */ | |
| .summary-preview { | |
| opacity: 0; | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| font-size: 0.75rem; | |
| color: rgba(255, 255, 255, 0.7); | |
| font-weight: 400; | |
| margin-top: 0; | |
| } | |
| .summary-preview.show { | |
| opacity: 1; | |
| max-height: 100px; | |
| margin-top: 0.5rem; | |
| } | |
| /* Side Drawer Styles */ | |
| .drawer-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.5); | |
| backdrop-filter: blur(4px); | |
| z-index: 50; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .drawer-overlay.open { | |
| opacity: 1; | |
| visibility: visible; | |
| } | |
| .drawer { | |
| position: fixed; | |
| top: 0; | |
| right: 0; | |
| width: 500px; | |
| height: 100%; | |
| background: linear-gradient(135deg, | |
| rgba(15, 23, 42, 0.95) 0%, | |
| rgba(15, 23, 42, 0.9) 100%); | |
| backdrop-filter: blur(25px) saturate(200%); | |
| border-left: 1px solid rgba(255, 255, 255, 0.2); | |
| box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3); | |
| transform: translateX(100%); | |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| z-index: 51; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .drawer.open { | |
| transform: translateX(0); | |
| } | |
| .drawer-header { | |
| padding: 24px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| flex-shrink: 0; | |
| } | |
| .drawer-content { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 24px; | |
| } | |
| .drawer-footer { | |
| padding: 24px; | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| flex-shrink: 0; | |
| display: flex; | |
| gap: 12px; | |
| justify-content: flex-end; | |
| } | |
| /* Drawer scrollbar */ | |
| .drawer-content::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .drawer-content::-webkit-scrollbar-track { | |
| background: rgba(148, 163, 184, 0.1); | |
| border-radius: 3px; | |
| } | |
| .drawer-content::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border-radius: 3px; | |
| } | |
| /* Edit button for complex sections - same style as preset buttons */ | |
| .edit-button { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 8px 12px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| color: white; | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.1) 0%, | |
| rgba(255, 255, 255, 0.05) 100%); | |
| backdrop-filter: blur(10px) saturate(180%); | |
| -webkit-backdrop-filter: blur(10px) saturate(180%); | |
| box-shadow: | |
| 0 4px 16px rgba(31, 38, 135, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.3); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-radius: 12px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .edit-button:hover { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.15) 0%, | |
| rgba(255, 255, 255, 0.08) 100%); | |
| box-shadow: | |
| 0 6px 20px rgba(31, 38, 135, 0.3), | |
| 0 0 0 2px rgba(255, 255, 255, 0.2), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.4); | |
| transform: translateY(-2px) scale(1.05); | |
| } | |
| /* Animated status indicator */ | |
| .status-indicator { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .status-indicator::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -100%; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); | |
| animation: shimmer 2s infinite; | |
| } | |
| @keyframes shimmer { | |
| 0% { left: -100%; } | |
| 100% { left: 100%; } | |
| } | |
| /* Floating animation */ | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0px); } | |
| 50% { transform: translateY(-10px); } | |
| } | |
| .float { | |
| animation: float 6s ease-in-out infinite; | |
| } | |
| /* Gradient text */ | |
| .gradient-text { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6, #ec4899); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| /* Enhanced card hover effects with glass morphism */ | |
| .card { | |
| transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| } | |
| .card:hover { | |
| transform: translateY(-12px) scale(1.03); | |
| box-shadow: | |
| 0 32px 64px rgba(31, 38, 135, 0.4), | |
| 0 16px 32px rgba(99, 102, 241, 0.2), | |
| inset 0 2px 0 rgba(255, 255, 255, 0.6); | |
| } | |
| .card:hover .glass { | |
| background: linear-gradient(135deg, | |
| rgba(255, 255, 255, 0.15) 0%, | |
| rgba(255, 255, 255, 0.08) 100%); | |
| border-color: rgba(255, 255, 255, 0.4); | |
| } | |
| .card:hover .glass-dark { | |
| background: linear-gradient(135deg, | |
| rgba(15, 23, 42, 0.6) 0%, | |
| rgba(15, 23, 42, 0.4) 100%); | |
| border-color: rgba(255, 255, 255, 0.3); | |
| } | |
| /* CodeMirror customization for glassmorphism theme */ | |
| .CodeMirror { | |
| background: transparent ; | |
| color: #e2e8f0 ; | |
| font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace ; | |
| font-size: 14px ; | |
| line-height: 24px ; | |
| height: 100% ; | |
| border: none ; | |
| padding: 32px ; | |
| } | |
| .CodeMirror-lines { | |
| padding: 0 ; | |
| } | |
| .CodeMirror pre { | |
| line-height: 24px ; | |
| padding: 0 ; | |
| } | |
| .CodeMirror-focused .CodeMirror-selected { | |
| background: rgba(99, 102, 241, 0.2) ; | |
| } | |
| .CodeMirror-selected { | |
| background: rgba(99, 102, 241, 0.1) ; | |
| } | |
| .CodeMirror-cursor { | |
| border-left: 2px solid #6366f1 ; | |
| } | |
| /* Custom line numbers styling */ | |
| #line-numbers { | |
| user-select: none; | |
| line-height: 24px; | |
| pointer-events: auto; /* Allow clicking fold arrows */ | |
| } | |
| #line-numbers .line-num { | |
| padding-right: 8px; | |
| font-size: 12px; | |
| color: rgba(255, 255, 255, 0.4); | |
| pointer-events: none; /* Prevent selection on numbers */ | |
| } | |
| .CodeMirror-activeline-background { | |
| background: rgba(255, 255, 255, 0.05) ; | |
| } | |
| /* YAML syntax highlighting colors for dark glassmorphism theme */ | |
| .cm-property { | |
| color: #60a5fa ; /* Blue for properties */ | |
| } | |
| .cm-string { | |
| color: #34d399 ; /* Green for strings */ | |
| } | |
| .cm-number { | |
| color: #fbbf24 ; /* Yellow for numbers */ | |
| } | |
| .cm-comment { | |
| color: rgba(255, 255, 255, 0.5) ; /* Muted for comments */ | |
| font-style: italic; | |
| } | |
| .cm-atom { | |
| color: #f472b6 ; /* Pink for booleans/null */ | |
| } | |
| /* Folded code indicator */ | |
| .CodeMirror-foldmarker { | |
| background: rgba(99, 102, 241, 0.2); | |
| border: 1px solid rgba(99, 102, 241, 0.4); | |
| color: rgba(255, 255, 255, 0.8); | |
| border-radius: 4px; | |
| padding: 0 4px; | |
| margin: 0 2px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| } | |
| .CodeMirror-foldmarker:hover { | |
| background: rgba(99, 102, 241, 0.3); | |
| } | |
| /* Style for inline fold arrows */ | |
| .cm-fold-arrow { | |
| color: rgba(99, 102, 241, 0.8) ; | |
| cursor: pointer ; | |
| padding: 2px ; | |
| border-radius: 2px ; | |
| background: rgba(99, 102, 241, 0.1) ; | |
| margin-left: 4px ; | |
| } | |
| .cm-fold-arrow:hover { | |
| background: rgba(99, 102, 241, 0.2) ; | |
| transform: scale(1.1) ; | |
| } | |
| /* Hover cursor for lines with arrows */ | |
| .CodeMirror-line:has-text("▼"), | |
| .CodeMirror-line:has-text("▶") { | |
| cursor: pointer; | |
| } | |
| .cm-keyword { | |
| color: #a78bfa ; /* Purple for keywords */ | |
| } | |
| .cm-variable { | |
| color: #e2e8f0 ; /* Light gray for variables */ | |
| } | |
| .cm-def { | |
| color: #60a5fa ; /* Blue for definitions */ | |
| } | |
| .cm-bracket { | |
| color: rgba(255, 255, 255, 0.8) ; /* Light for brackets */ | |
| } | |
| .cm-tag { | |
| color: #f87171 ; /* Red for tags */ | |
| } | |
| .cm-link { | |
| color: #60a5fa ; /* Blue for links */ | |
| } | |
| .cm-error { | |
| background: rgba(239, 68, 68, 0.2) ; | |
| color: #fca5a5 ; | |
| } | |
| /* Scrollbar for CodeMirror */ | |
| .CodeMirror-scrollbar-filler { | |
| background: transparent ; | |
| } | |
| .CodeMirror-vscrollbar::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| .CodeMirror-vscrollbar::-webkit-scrollbar-track { | |
| background: rgba(148, 163, 184, 0.1); | |
| border-radius: 4px; | |
| } | |
| .CodeMirror-vscrollbar::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border-radius: 4px; | |
| } | |
| .CodeMirror-vscrollbar::-webkit-scrollbar-thumb:hover { | |
| background: linear-gradient(135deg, #4f46e5, #7c3aed); | |
| } | |
| .CodeMirror-hscrollbar::-webkit-scrollbar { | |
| height: 8px; | |
| } | |
| .CodeMirror-hscrollbar::-webkit-scrollbar-track { | |
| background: rgba(148, 163, 184, 0.1); | |
| border-radius: 4px; | |
| } | |
| .CodeMirror-hscrollbar::-webkit-scrollbar-thumb { | |
| background: linear-gradient(135deg, #6366f1, #8b5cf6); | |
| border-radius: 4px; | |
| } | |
| .CodeMirror-hscrollbar::-webkit-scrollbar-thumb:hover { | |
| background: linear-gradient(135deg, #4f46e5, #7c3aed); | |
| } | |
| </style> | |
| </head> | |
| <body class="h-full antialiased"> | |
| <div id="app" class="flex flex-col h-full"> | |
| <!-- Preset Configuration Buttons --> | |
| <section class="border-b border-white/10 bg-gradient-to-r from-white/5 to-white/2 backdrop-blur-sm"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> | |
| <div class="flex items-center space-x-2 mb-3"> | |
| <svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path> | |
| </svg> | |
| <h3 class="text-sm font-semibold text-white/80" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);">Quick Start Templates</h3> | |
| </div> | |
| <div class="flex flex-wrap gap-3"> | |
| <button id="preset-open-r1" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">🧠</span> | |
| <span>Open-R1</span> | |
| </div> | |
| </button> | |
| <button id="preset-model-serving" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">🚀</span> | |
| <span>Model Serving</span> | |
| </div> | |
| </button> | |
| <button id="preset-jupyter-dev" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">📊</span> | |
| <span>Jupyter Dev</span> | |
| </div> | |
| </button> | |
| <button id="preset-data-processing" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">⚡</span> | |
| <span>Data Processing</span> | |
| </div> | |
| </button> | |
| <button id="preset-web-app" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">🌐</span> | |
| <span>Web App</span> | |
| </div> | |
| </button> | |
| <button id="preset-gpu-cluster" class="preset-btn glass-preset px-4 py-2.5 text-sm font-medium text-white rounded-xl transition-all hover:scale-105 border border-white/20"> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-lg">🔥</span> | |
| <span>GPU Cluster</span> | |
| </div> | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Main Content: Side-by-side editors --> | |
| <main class="flex-grow p-6 sm:p-8 lg:p-10 grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-7xl mx-auto w-full"> | |
| <!-- Left Side: Form-based UI Editor --> | |
| <div class="flex flex-col glass rounded-2xl shadow-2xl ring-1 ring-white/20 overflow-hidden card glow"> | |
| <div class="flex-shrink-0 bg-gradient-to-r from-violet-500/20 to-purple-500/20 border-b border-white/20 px-8 py-6"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center space-x-3"> | |
| <div class="w-3 h-3 bg-gradient-to-r from-green-400 to-blue-500 rounded-full animate-pulse"></div> | |
| <h2 class="text-xl font-bold text-white drop-shadow-lg">Visual Editor</h2> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div> | |
| <span class="text-xs text-white/70 font-medium">Live Preview</span> | |
| </div> | |
| </div> | |
| <p class="text-sm text-white/80 mt-2 font-medium">Craft your dstack configuration with style</p> | |
| </div> | |
| <div class="flex-grow bg-gradient-to-b from-white/5 to-white/1 overflow-hidden"> | |
| <div id="form-container" class="h-full p-6 space-y-4 overflow-y-auto"> | |
| <!-- Dynamic form content will be injected here --> | |
| </div> | |
| <div id="empty-state" class="flex flex-col items-center justify-center h-64 text-center p-8" style="display: none;"> | |
| <div class="w-16 h-16 bg-gradient-to-r from-violet-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mb-4"> | |
| <svg class="w-8 h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> | |
| </svg> | |
| </div> | |
| <h3 class="text-lg font-semibold text-white/80 mb-2">Ready to Build</h3> | |
| <p class="text-sm text-white/60 max-w-md">Select a template above or start editing the YAML to create your dstack configuration</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Side: Raw YAML Text Editor --> | |
| <div class="flex flex-col glass-dark rounded-2xl shadow-2xl overflow-hidden card glow"> | |
| <div class="flex-shrink-0 bg-gradient-to-r from-slate-900/50 to-slate-800/50 border-b border-white/10 px-8 py-6 flex justify-between items-center"> | |
| <div class="flex items-center space-x-3"> | |
| <div class="w-3 h-3 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full animate-pulse"></div> | |
| <div> | |
| <h2 class="text-xl font-bold text-white drop-shadow-lg">dstack.yml</h2> | |
| <p class="text-sm text-white/70 mt-1 font-medium">Raw YAML configuration</p> | |
| </div> | |
| </div> | |
| <!-- Error/Success indicator --> | |
| <div id="status-indicator" class="status-indicator px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-full transition-all border border-white/20"> </div> | |
| </div> | |
| <div class="flex-grow relative flex"> | |
| <div id="line-numbers" class="flex-shrink-0 w-12 bg-slate-900/30 border-r border-white/10 text-right text-xs text-white/40 font-mono leading-6 pt-8 pr-2 overflow-hidden"></div> | |
| <div id="yaml-editor-container" class="flex-grow h-full"></div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <!-- Side Drawer --> | |
| <div id="drawer-overlay" class="drawer-overlay" onclick="closeDrawer()"></div> | |
| <div id="drawer" class="drawer"> | |
| <div class="drawer-header"> | |
| <div class="flex items-center justify-between"> | |
| <h2 id="drawer-title" class="text-xl font-bold text-white">Edit Section</h2> | |
| <button onclick="closeDrawer()" class="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-all"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| <p class="text-sm text-white/70 mt-2">Configure this section in detail</p> | |
| </div> | |
| <div id="drawer-content" class="drawer-content"> | |
| <!-- Dynamic content will be rendered here --> | |
| </div> | |
| <div class="drawer-footer"> | |
| <button onclick="closeDrawer()" class="px-4 py-2 text-sm font-semibold text-white/80 bg-white/10 border border-white/20 rounded-lg hover:bg-white/20 transition-all"> | |
| Cancel | |
| </button> | |
| <button onclick="saveDrawer()" class="px-6 py-2 text-sm font-semibold text-white bg-gradient-to-r from-green-500 to-emerald-500 rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all"> | |
| Save Changes | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // --- DOM Element References --- | |
| const formContainer = document.getElementById('form-container'); | |
| const yamlEditorContainer = document.getElementById('yaml-editor-container'); | |
| const statusIndicator = document.getElementById('status-indicator'); | |
| // --- Initialize CodeMirror --- | |
| const lineNumbersDiv = document.getElementById('line-numbers'); | |
| const yamlEditor = CodeMirror(yamlEditorContainer, { | |
| mode: 'yaml', | |
| theme: 'material-darker', | |
| lineNumbers: false, // Disable built-in line numbers | |
| lineWrapping: true, | |
| indentUnit: 2, | |
| tabSize: 2, | |
| autoCloseBrackets: true, | |
| matchBrackets: true, | |
| showCursorWhenSelecting: true, | |
| styleActiveLine: true, | |
| readOnly: false, // Make sure editor is editable | |
| value: '', | |
| placeholder: '# dstack configuration\ntype: task\nname: my-awesome-task\npython: "3.11"\ncommands:\n - pip install requirements.txt\n - python main.py', | |
| extraKeys: { | |
| "Enter": function(cm) { | |
| // Check if current line has an arrow | |
| const cursor = cm.getCursor(); | |
| const line = cm.getLine(cursor.line); | |
| if (line && (line.includes(' ▼') || line.includes(' ▶'))) { | |
| // Toggle section instead of adding new line | |
| const cleanLine = line.replace(/ [▼▶]/g, '').trim(); | |
| const sectionKey = cleanLine.replace(':', ''); | |
| if (collapsedSections.has(sectionKey)) { | |
| collapsedSections.delete(sectionKey); | |
| } else { | |
| collapsedSections.add(sectionKey); | |
| } | |
| displayCollapsedYaml(); | |
| return false; // Prevent default behavior | |
| } | |
| return CodeMirror.Pass; // Allow normal Enter behavior | |
| } | |
| } | |
| }); | |
| // Simple collapsing state | |
| let collapsedSections = new Set(); | |
| let isUpdatingDisplay = false; | |
| let originalContent = ''; | |
| let originalSections = []; | |
| // Get collapsible YAML sections | |
| const getCollapsibleSections = () => { | |
| const content = yamlEditor.getValue(); | |
| const lines = content.split('\n'); | |
| const sections = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| const trimmed = line.trim(); | |
| // Check if this is a YAML key that can be collapsed | |
| if (trimmed.endsWith(':') && !trimmed.startsWith('-') && !trimmed.startsWith('#')) { | |
| const currentIndent = line.length - line.trimStart().length; | |
| const childLines = []; | |
| // Find child lines | |
| for (let j = i + 1; j < lines.length; j++) { | |
| const nextLine = lines[j]; | |
| if (!nextLine || nextLine.trim() === '') continue; | |
| const nextIndent = nextLine.length - nextLine.trimStart().length; | |
| if (nextIndent > currentIndent) { | |
| childLines.push(j); | |
| } else { | |
| break; | |
| } | |
| } | |
| if (childLines.length > 0) { | |
| sections.push({ | |
| headerLine: i, | |
| childLines: childLines, | |
| key: trimmed.replace(':', '') | |
| }); | |
| } | |
| } | |
| } | |
| return sections; | |
| }; | |
| // Add arrows to YAML content without modifying the original | |
| const addArrowsToContent = () => { | |
| if (isUpdatingDisplay) return; | |
| isUpdatingDisplay = true; | |
| const content = yamlEditor.getValue(); | |
| console.log('Current content:', content); | |
| if (content.includes(' ▼') || content.includes(' ▶')) { | |
| console.log('Already has arrows, skipping'); | |
| isUpdatingDisplay = false; | |
| return; // Already has arrows | |
| } | |
| // Store original content and sections for later use | |
| originalContent = content; | |
| originalSections = getCollapsibleSections(); | |
| console.log('Stored original content and sections:', originalSections.length); | |
| const lines = content.split('\n'); | |
| const sections = originalSections; | |
| console.log('Found sections:', sections); | |
| sections.forEach(section => { | |
| const isCollapsed = collapsedSections.has(section.key); | |
| const arrow = isCollapsed ? ' ▶' : ' ▼'; | |
| console.log(`Adding ${arrow} to line ${section.headerLine}: "${lines[section.headerLine]}"`); | |
| lines[section.headerLine] += arrow; | |
| }); | |
| const newContent = lines.join('\n'); | |
| console.log('New content with arrows:', newContent); | |
| if (newContent !== content) { | |
| // Temporarily disable change events | |
| yamlEditor.off('change'); | |
| yamlEditor.setValue(newContent); | |
| // Re-enable change events | |
| setTimeout(() => { | |
| yamlEditor.on('change', () => { | |
| if (!isUpdatingDisplay) { | |
| updateFormFromYaml(); | |
| setTimeout(() => { | |
| addArrowsToContent(); | |
| }, 200); | |
| } | |
| }); | |
| isUpdatingDisplay = false; | |
| }, 50); | |
| } else { | |
| isUpdatingDisplay = false; | |
| } | |
| // Update line numbers | |
| const lineCount = yamlEditor.lineCount(); | |
| let lineNumbersHTML = ''; | |
| for (let i = 1; i <= lineCount; i++) { | |
| lineNumbersHTML += `<div class="line-num" style="height: 24px;">${i}</div>`; | |
| } | |
| lineNumbersDiv.innerHTML = lineNumbersHTML; | |
| }; | |
| // Toggle collapse and update arrows | |
| const toggleSection = (sectionKey) => { | |
| console.log('Toggling section:', sectionKey); | |
| if (collapsedSections.has(sectionKey)) { | |
| collapsedSections.delete(sectionKey); | |
| } else { | |
| collapsedSections.add(sectionKey); | |
| } | |
| // Update the display with collapsed content | |
| updateCollapsedDisplay(); | |
| }; | |
| // Update display with collapsed content | |
| const updateCollapsedDisplay = () => { | |
| if (isUpdatingDisplay) return; | |
| isUpdatingDisplay = true; | |
| console.log('=== UPDATE COLLAPSED DISPLAY ==='); | |
| // Use original content for expansion, current content for initial parsing | |
| const content = originalContent || yamlEditor.getValue(); | |
| const lines = content.split('\n'); | |
| const sections = originalSections.length > 0 ? originalSections : getCollapsibleSections(); | |
| const displayLines = []; | |
| console.log('Current collapsed sections:', Array.from(collapsedSections)); | |
| console.log('Available sections:', sections.map(s => s.key)); | |
| console.log('Using original content:', !!originalContent); | |
| for (let i = 0; i < lines.length; i++) { | |
| const section = sections.find(s => s.headerLine === i); | |
| if (section) { | |
| // This is a collapsible section header | |
| const isCollapsed = collapsedSections.has(section.key); | |
| const arrow = isCollapsed ? ' ▶' : ' ▼'; | |
| console.log(`Section ${section.key} at line ${i}: isCollapsed=${isCollapsed}, childLines=${JSON.stringify(section.childLines)}`); | |
| // Always show the header with appropriate arrow | |
| const cleanLine = lines[i].replace(/ [▼▶]/g, ''); | |
| displayLines.push(cleanLine + arrow); | |
| // Add child lines only if not collapsed | |
| if (!isCollapsed) { | |
| console.log(`Adding child lines for ${section.key}:`, section.childLines); | |
| section.childLines.forEach(childLineIndex => { | |
| if (lines[childLineIndex]) { | |
| console.log(` Adding child line ${childLineIndex}: ${lines[childLineIndex]}`); | |
| displayLines.push(lines[childLineIndex]); | |
| } | |
| }); | |
| } else { | |
| console.log(`Skipping child lines for collapsed section ${section.key}`); | |
| } | |
| // Skip the child lines in the main loop | |
| i = section.childLines.length > 0 ? Math.max(...section.childLines) : i; | |
| } else { | |
| // Regular line - check if it's a child of a collapsed section | |
| const parentSection = sections.find(s => s.childLines.includes(i)); | |
| if (!parentSection || !collapsedSections.has(parentSection.key)) { | |
| displayLines.push(lines[i]); | |
| } | |
| } | |
| } | |
| const newContent = displayLines.join('\n'); | |
| const cursor = yamlEditor.getCursor(); | |
| console.log('Final display lines:', displayLines); | |
| console.log('New content length:', newContent.length); | |
| // Prevent triggering change events during update | |
| yamlEditor.off('change'); | |
| yamlEditor.setValue(newContent); | |
| yamlEditor.setCursor(Math.min(cursor.line, displayLines.length - 1), cursor.ch); | |
| // Re-enable change events | |
| setTimeout(() => { | |
| yamlEditor.on('change', () => { | |
| if (!isUpdatingDisplay) { | |
| updateFormFromYaml(); | |
| setTimeout(() => { | |
| addArrowsToContent(); | |
| }, 200); | |
| } | |
| }); | |
| isUpdatingDisplay = false; | |
| }, 50); | |
| // Update line numbers | |
| const lineCount = displayLines.length; | |
| let lineNumbersHTML = ''; | |
| for (let i = 1; i <= lineCount; i++) { | |
| lineNumbersHTML += `<div class="line-num" style="height: 24px;">${i}</div>`; | |
| } | |
| lineNumbersDiv.innerHTML = lineNumbersHTML; | |
| }; | |
| // Use DOM-based click detection instead of CodeMirror coordinates | |
| yamlEditorContainer.addEventListener('click', (event) => { | |
| console.log('=== DOM CLICK DEBUG ==='); | |
| // Find which line element was clicked | |
| const target = event.target; | |
| const lineElement = target.closest('.CodeMirror-line'); | |
| if (lineElement) { | |
| // Get the text content of the clicked line | |
| const lineText = lineElement.textContent || lineElement.innerText; | |
| console.log('Clicked line text:', JSON.stringify(lineText)); | |
| console.log('Has ▼?', lineText.includes('▼')); | |
| console.log('Has ▶?', lineText.includes('▶')); | |
| if (lineText.includes('▼') || lineText.includes('▶')) { | |
| console.log('Arrow detected in clicked line!'); | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| // Extract section key from the line text | |
| const cleanLine = lineText.replace(/[▼▶]/g, '').trim(); | |
| const sectionKey = cleanLine.replace(':', ''); | |
| console.log('Raw line text:', JSON.stringify(lineText)); | |
| console.log('Clean line:', JSON.stringify(cleanLine)); | |
| console.log('Section key:', JSON.stringify(sectionKey)); | |
| console.log('Current collapsed sections before toggle:', Array.from(collapsedSections)); | |
| toggleSection(sectionKey); | |
| console.log('Current collapsed sections after toggle:', Array.from(collapsedSections)); | |
| return false; | |
| } else { | |
| console.log('No arrow in clicked line'); | |
| } | |
| } else { | |
| console.log('Could not find line element'); | |
| } | |
| }); | |
| // Keep the CodeMirror handler as backup | |
| yamlEditor.on('mousedown', (cm, event) => { | |
| // This is now just for debugging | |
| const pos = cm.coordsChar({left: event.clientX, top: event.clientY}); | |
| console.log('CodeMirror detected click on line:', pos.line, 'Content:', cm.getLine(pos.line)); | |
| }); | |
| // Handle cursor change on hover | |
| yamlEditor.on('mousemove', (cm, event) => { | |
| const pos = cm.coordsChar({left: event.clientX, top: event.clientY}); | |
| const line = cm.getLine(pos.line); | |
| if (line && (line.includes(' ▼') || line.includes(' ▶'))) { | |
| yamlEditorContainer.style.cursor = 'pointer'; | |
| } else { | |
| yamlEditorContainer.style.cursor = 'text'; | |
| } | |
| }); | |
| // Auto-collapse all sections by default | |
| const autoCollapseAll = () => { | |
| const sections = getCollapsibleSections(); | |
| sections.forEach(section => { | |
| collapsedSections.add(section.key); | |
| }); | |
| updateCollapsedDisplay(); | |
| }; | |
| // Sync scrolling between line numbers and editor | |
| yamlEditor.on('scroll', () => { | |
| const scrollInfo = yamlEditor.getScrollInfo(); | |
| lineNumbersDiv.scrollTop = scrollInfo.top; | |
| }); | |
| // Update display when content changes | |
| yamlEditor.on('change', () => { | |
| if (!isUpdatingDisplay) { | |
| updateFormFromYaml(); | |
| // Add arrows after changes (but not too frequently) | |
| setTimeout(() => { | |
| addArrowsToContent(); | |
| }, 200); | |
| } | |
| }); | |
| // Initial display - don't add arrows yet, wait for template to load | |
| console.log('Waiting for template to load before adding arrows...'); | |
| // Also add a global function to test manually | |
| window.testArrows = () => { | |
| console.log('Manual arrow test...'); | |
| addArrowsToContent(); | |
| }; | |
| // Add a simple test function to directly toggle sections | |
| window.testToggle = (sectionKey) => { | |
| console.log('=== MANUAL TOGGLE TEST ==='); | |
| console.log('Toggling section:', sectionKey); | |
| console.log('Before:', Array.from(collapsedSections)); | |
| toggleSection(sectionKey); | |
| console.log('After:', Array.from(collapsedSections)); | |
| }; | |
| // --- dstack Templates --- | |
| const dstackTemplates = { | |
| task: `type: task | |
| name: my-task | |
| python: "3.11" | |
| commands: | |
| - pip install -r requirements.txt | |
| - python train.py | |
| resources: | |
| gpu: 24GB | |
| memory: 32GB | |
| ports: | |
| - 8080 | |
| env: | |
| - MODEL_NAME=llama2 | |
| - BATCH_SIZE=32`, | |
| service: `type: service | |
| name: my-service | |
| image: ghcr.io/huggingface/text-generation-inference:latest | |
| env: | |
| - MODEL_ID=microsoft/DialoGPT-medium | |
| commands: | |
| - text-generation-launcher --port 8000 --trust-remote-code | |
| port: 8000 | |
| resources: | |
| gpu: 80GB | |
| memory: 64GB | |
| model: | |
| type: chat | |
| name: microsoft/DialoGPT-medium | |
| format: tgi | |
| replicas: 1..4 | |
| rate_limits: | |
| - prefix: /api/ | |
| rps: 10 | |
| burst: 20`, | |
| 'dev-environment': `type: dev-environment | |
| name: my-dev-env | |
| python: "3.11" | |
| ide: vscode | |
| commands: | |
| - pip install -r requirements.txt | |
| - pip install jupyter | |
| resources: | |
| gpu: 24GB | |
| memory: 32GB | |
| cpu: 8 | |
| disk: 100GB | |
| ports: | |
| - 8888 | |
| env: | |
| - JUPYTER_TOKEN=my-secret-token` | |
| }; | |
| // --- Preset Button Event Handlers --- | |
| const loadPresetConfig = (presetType) => { | |
| if (presetConfigs[presetType]) { | |
| if (yamlEditor.getValue().trim() === '' || confirm('This will replace the current content. Are you sure?')) { | |
| yamlEditor.setValue(presetConfigs[presetType]); | |
| updateFormFromYaml(); | |
| // Auto-fold after preset load | |
| setTimeout(autoFoldAll, 150); | |
| } | |
| } | |
| }; | |
| // --- Mock Preset Configurations --- | |
| const presetConfigs = { | |
| 'open-r1': `type: task | |
| name: open-r1 | |
| python: "3.11" | |
| env: | |
| - HF_TOKEN | |
| - HUGGINGFACE_TOKEN | |
| - WANDB_API_KEY | |
| - E2B_API_KEY | |
| - TARGET_BASE_MODEL | |
| - TARGET_YAML | |
| - TARGET_REWARD | |
| commands: | |
| - apt-get update && apt-get install -y wget gnupg | |
| - wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/cuda-keyring_1.1-1_all.deb | |
| - dpkg -i cuda-keyring_1.1-1_all.deb | |
| - rm -f /etc/apt/sources.list.d/cuda*.list | |
| - wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/cuda-ubuntu2004.pin | |
| - mv cuda-ubuntu2004.pin /etc/apt/preferences.d/cuda-repository-pin-600 | |
| - wget https://developer.download.nvidia.com/compute/cuda/12.4.0/local_installers/cuda-repo-ubuntu2004-12-4-local_12.4.0-550.54.14-1_amd64.deb | |
| - dpkg -i cuda-repo-ubuntu2004-12-4-local_12.4.0-550.54.14-1_amd64.deb | |
| - cp /var/cuda-repo-ubuntu2004-12-4-local/cuda-*-keyring.gpg /usr/share/keyrings/ | |
| - apt-get update | |
| - apt-get install -y cuda-toolkit-12-4 | |
| - echo 'export PATH=/usr/local/cuda-12.4/bin:$PATH' >> ~/.bashrc | |
| - echo 'export LD_LIBRARY_PATH=/usr/local/cuda-12.4/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc | |
| - source ~/.bashrc | |
| - nvcc --version | |
| - curl -LsSf https://astral.sh/uv/install.sh | sh | |
| - source ~/.bashrc | |
| - uv venv openr1 --python 3.11 && source openr1/bin/activate && uv pip install --upgrade pip | |
| - uv pip install vllm==0.8.4 | |
| - uv pip install setuptools && uv pip install flash-attn --no-build-isolation | |
| - git clone https://github.com/deep-diver/open-r1.git | |
| - cp $TARGET_YAML open-r1/recipes/custom.yaml | |
| - cat open-r1/recipes/custom.yaml | |
| - cp $TARGET_REWARD open-r1/src/open_r1/code_rewards.py | |
| - cat open-r1/src/open_r1/code_rewards.py | |
| - cd open-r1 | |
| - echo "E2B_API_KEY=$E2B_API_KEY" >> ".env" | |
| - cat .env | |
| - GIT_LFS_SKIP_SMUDGE=1 uv pip install -e ".[dev]" | |
| - git clone https://github.com/deep-diver/trl.git | |
| - cd trl | |
| - uv pip install . | |
| - nohup bash -c 'CUDA_VISIBLE_DEVICES=0 trl vllm-serve --model "$TARGET_BASE_MODEL"' > vllm.log 2>&1 & | |
| - sleep 420 | |
| - cd .. | |
| - CUDA_VISIBLE_DEVICES=1,2,3,4,5,6,7 ACCELERATE_LOG_LEVEL=info accelerate launch --config_file recipes/accelerate_configs/zero2.yaml --num_processes=7 src/open_r1/grpo.py --config recipes/custom.yaml | |
| - pkill -f 'trl vllm-serve' | |
| resources: | |
| gpu: 80GB:8 | |
| disk: 600GB | |
| shm_size: 2GB`, | |
| 'llm-training': `type: task | |
| name: llm-training | |
| python: "3.11" | |
| commands: | |
| - pip install torch transformers datasets accelerate | |
| - python -m torch.distributed.launch --nproc_per_node=4 train.py | |
| - python evaluate.py --checkpoint ./best_model | |
| resources: | |
| gpu: 80GB:4 | |
| memory: 256GB | |
| cpu: 32 | |
| disk: 1TB | |
| env: | |
| - CUDA_VISIBLE_DEVICES=0,1,2,3 | |
| - TRANSFORMERS_CACHE=/opt/cache | |
| - WANDB_PROJECT=llm-training | |
| - MODEL_NAME=meta-llama/Llama-2-70b-hf | |
| - BATCH_SIZE=8 | |
| - LEARNING_RATE=1e-5`, | |
| 'model-serving': `type: service | |
| name: llm-api-server | |
| image: ghcr.io/huggingface/text-generation-inference:latest | |
| env: | |
| - MODEL_ID=microsoft/DialoGPT-large | |
| - MAX_CONCURRENT_REQUESTS=128 | |
| - MAX_INPUT_LENGTH=1024 | |
| - MAX_TOTAL_TOKENS=2048 | |
| commands: | |
| - text-generation-launcher --port 8000 --trust-remote-code --quantize bitsandbytes | |
| port: 8000 | |
| resources: | |
| gpu: 40GB | |
| memory: 64GB | |
| cpu: 16 | |
| model: | |
| type: chat | |
| name: microsoft/DialoGPT-large | |
| format: tgi | |
| replicas: 2..8 | |
| rate_limits: | |
| - prefix: /api/v1/chat | |
| rps: 50 | |
| burst: 100 | |
| - prefix: /health | |
| rps: 200`, | |
| 'jupyter-dev': `type: dev-environment | |
| name: ml-jupyter-workspace | |
| python: "3.11" | |
| ide: jupyter | |
| commands: | |
| - pip install jupyter jupyterlab pandas numpy matplotlib seaborn scikit-learn | |
| - pip install torch torchvision transformers datasets | |
| - jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root | |
| resources: | |
| gpu: 24GB | |
| memory: 64GB | |
| cpu: 16 | |
| disk: 500GB | |
| ports: | |
| - 8888 | |
| - 6006 | |
| env: | |
| - JUPYTER_TOKEN=secure-token-123 | |
| - NVIDIA_VISIBLE_DEVICES=all | |
| - PYTHONPATH=/workspace`, | |
| 'data-processing': `type: task | |
| name: data-pipeline | |
| python: "3.11" | |
| commands: | |
| - pip install pandas polars dask distributed apache-beam | |
| - python extract_data.py --source s3://my-bucket/raw/ | |
| - python transform_data.py --workers 16 | |
| - python load_data.py --target s3://my-bucket/processed/ | |
| resources: | |
| memory: 128GB | |
| cpu: 32 | |
| disk: 2TB | |
| env: | |
| - AWS_ACCESS_KEY_ID=your-access-key | |
| - AWS_SECRET_ACCESS_KEY=your-secret-key | |
| - DASK_WORKERS=16 | |
| - CHUNK_SIZE=10000`, | |
| 'web-app': `type: service | |
| name: streamlit-demo | |
| python: "3.11" | |
| commands: | |
| - pip install streamlit plotly pandas numpy | |
| - streamlit run app.py --server.port 8501 --server.address 0.0.0.0 | |
| port: 8501 | |
| resources: | |
| memory: 8GB | |
| cpu: 4 | |
| replicas: 1..3 | |
| rate_limits: | |
| - rps: 100 | |
| burst: 200 | |
| env: | |
| - STREAMLIT_THEME_BASE=dark | |
| - STREAMLIT_SERVER_ENABLE_CORS=true`, | |
| 'gpu-cluster': `type: task | |
| name: distributed-training | |
| python: "3.11" | |
| commands: | |
| - pip install torch torchvision transformers accelerate deepspeed | |
| - accelerate launch --multi_gpu --num_processes 8 train_distributed.py | |
| - python consolidate_checkpoints.py | |
| resources: | |
| gpu: 40GB:8 | |
| memory: 512GB | |
| cpu: 64 | |
| disk: 5TB | |
| env: | |
| - MASTER_ADDR=localhost | |
| - MASTER_PORT=29500 | |
| - WORLD_SIZE=8 | |
| - NCCL_DEBUG=INFO | |
| - CUDA_LAUNCH_BLOCKING=1 | |
| - MODEL_PARALLEL_SIZE=4 | |
| - DATA_PARALLEL_SIZE=2` | |
| }; | |
| // --- Initial State & Data --- | |
| let currentData = {}; // Holds the parsed YAML as a JS object | |
| let isUpdating = false; // Prevents infinite update loops | |
| // --- Utility Functions --- | |
| /** | |
| * Sets a value in a nested object based on a path string. | |
| */ | |
| const setNestedValue = (obj, path, value) => { | |
| const keys = path.split('.'); | |
| let current = obj; | |
| for (let i = 0; i < keys.length - 1; i++) { | |
| const key = keys[i]; | |
| const nextKey = keys[i + 1]; | |
| if (!isNaN(parseInt(nextKey, 10)) && !Array.isArray(current[key])) { | |
| current[key] = []; | |
| } else if (isNaN(parseInt(nextKey, 10)) && typeof current[key] !== 'object') { | |
| current[key] = {}; | |
| } | |
| current = current[key]; | |
| } | |
| current[keys[keys.length - 1]] = value; | |
| }; | |
| /** | |
| * Deletes a key or an array element from a nested object. | |
| */ | |
| const deleteNestedValue = (obj, path) => { | |
| const keys = path.split('.'); | |
| let current = obj; | |
| for (let i = 0; i < keys.length - 1; i++) { | |
| current = current[keys[i]]; | |
| } | |
| const finalKey = keys[keys.length - 1]; | |
| if (Array.isArray(current)) { | |
| current.splice(parseInt(finalKey, 10), 1); | |
| } else { | |
| delete current[finalKey]; | |
| } | |
| }; | |
| // --- Core Rendering Functions --- | |
| /** | |
| * Generates an input field based on the value type and dstack context. | |
| */ | |
| const createInputField = (key, value, path) => { | |
| const type = typeof value; | |
| const id = `field-${path}`; | |
| let inputHtml = ''; | |
| // Common classes for text-based inputs with compact styling | |
| const inputClasses = "form-input mt-1 block w-full rounded-lg border-white/20 shadow-lg focus:border-violet-400 focus:ring-violet-400 text-sm font-medium placeholder-white/60 py-2.5"; | |
| const selectClasses = "form-select mt-1 block w-full rounded-lg border-white/20 shadow-lg focus:border-violet-400 focus:ring-violet-400 text-sm font-medium py-2.5"; | |
| // Get field icon SVG based on key | |
| const getFieldIcon = (key) => { | |
| const iconMap = { | |
| 'type': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path></svg>', | |
| 'name': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>', | |
| 'python': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>', | |
| 'image': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>', | |
| 'commands': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>', | |
| 'ports': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path></svg>', | |
| 'env': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>', | |
| 'resources': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path></svg>', | |
| 'gpu': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path></svg>', | |
| 'memory': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>', | |
| 'cpu': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path></svg>', | |
| 'disk': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"></path></svg>', | |
| 'replicas': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>', | |
| 'rate_limits': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>', | |
| 'rps': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>', | |
| 'burst': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>', | |
| 'model': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path></svg>', | |
| 'format': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>', | |
| 'prefix': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>', | |
| 'ide': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>', | |
| 'value': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>' | |
| }; | |
| return iconMap[key] || '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>'; | |
| }; | |
| const fieldIcon = getFieldIcon(key); | |
| const fieldLabel = String(key).replace(/_/g, ' '); | |
| // dstack-specific field handling | |
| if (key === 'type' && typeof value === 'string') { | |
| inputHtml = ` | |
| <div class="relative"> | |
| <select data-path="${path}" id="${id}" class="${selectClasses} pl-12 appearance-none cursor-pointer"> | |
| <option value="task" ${value === 'task' ? 'selected' : ''}>Task</option> | |
| <option value="service" ${value === 'service' ? 'selected' : ''}>Service</option> | |
| <option value="dev-environment" ${value === 'dev-environment' ? 'selected' : ''}>Dev Environment</option> | |
| </select> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/60"> | |
| ${fieldIcon} | |
| </div> | |
| <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> | |
| <svg class="h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> | |
| </svg> | |
| </div> | |
| </div>`; | |
| } else if (key === 'python' && typeof value === 'string') { | |
| inputHtml = ` | |
| <div class="relative"> | |
| <select data-path="${path}" id="${id}" class="${selectClasses} pl-12 appearance-none cursor-pointer"> | |
| <option value="3.8" ${value === '3.8' ? 'selected' : ''}>Python 3.8</option> | |
| <option value="3.9" ${value === '3.9' ? 'selected' : ''}>Python 3.9</option> | |
| <option value="3.10" ${value === '3.10' ? 'selected' : ''}>Python 3.10</option> | |
| <option value="3.11" ${value === '3.11' ? 'selected' : ''}>Python 3.11</option> | |
| <option value="3.12" ${value === '3.12' ? 'selected' : ''}>Python 3.12</option> | |
| </select> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/60"> | |
| ${fieldIcon} | |
| </div> | |
| <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> | |
| <svg class="h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> | |
| </svg> | |
| </div> | |
| </div>`; | |
| } else if (key === 'ide' && typeof value === 'string') { | |
| inputHtml = ` | |
| <div class="relative"> | |
| <select data-path="${path}" id="${id}" class="${selectClasses} pl-12 appearance-none cursor-pointer"> | |
| <option value="vscode" ${value === 'vscode' ? 'selected' : ''}>VS Code</option> | |
| <option value="jupyter" ${value === 'jupyter' ? 'selected' : ''}>Jupyter</option> | |
| <option value="ssh" ${value === 'ssh' ? 'selected' : ''}>SSH</option> | |
| </select> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/60"> | |
| ${fieldIcon} | |
| </div> | |
| <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> | |
| <svg class="h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> | |
| </svg> | |
| </div> | |
| </div>`; | |
| } else if (type === 'boolean') { | |
| inputHtml = ` | |
| <div class="relative"> | |
| <div class="flex items-center mt-3 p-4 glass rounded-xl border border-white/10 hover:border-white/20 transition-all group"> | |
| <div class="flex items-center"> | |
| <input type="checkbox" data-path="${path}" id="${id}" ${value ? 'checked' : ''} class="h-5 w-5 rounded-lg border-white/30 text-violet-600 focus:ring-violet-500 focus:ring-offset-2 transition-all"> | |
| <div class="ml-4 flex items-center space-x-3"> | |
| <div class="text-white/60">${fieldIcon}</div> | |
| <label for="${id}" class="text-sm font-semibold transition-colors ${value ? 'text-green-400' : 'text-white/70'}">${value ? 'Enabled' : 'Disabled'}</label> | |
| </div> | |
| </div> | |
| <div class="ml-auto"> | |
| <div class="w-6 h-6 rounded-full flex items-center justify-center ${value ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${value ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'}"></path> | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| } else if (type === 'number') { | |
| inputHtml = ` | |
| <div class="relative"> | |
| <input type="number" data-path="${path}" id="${id}" value="${value}" class="${inputClasses} pl-12" placeholder="Enter number..."> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/60"> | |
| ${fieldIcon} | |
| </div> | |
| </div>`; | |
| } else { // string or other | |
| let placeholder = `Enter ${fieldLabel.toLowerCase()}...`; | |
| if (key === 'name') placeholder = 'my-awesome-project'; | |
| else if (key === 'image') placeholder = 'ghcr.io/huggingface/transformers'; | |
| else if (key === 'value') placeholder = 'Enter value...'; | |
| // Calculate rows based on content | |
| const lines = value.toString().split('\n').length; | |
| const rows = Math.max(1, lines); | |
| inputHtml = ` | |
| <div class="relative"> | |
| <textarea data-path="${path}" id="${id}" class="${inputClasses} pl-12 min-h-[2.5rem] resize-y auto-resize" placeholder="${placeholder}" rows="${rows}">${value}</textarea> | |
| <div class="absolute top-3 left-0 pl-3 flex items-center pointer-events-none text-white/60"> | |
| ${fieldIcon} | |
| </div> | |
| </div>`; | |
| } | |
| // Add compact help text for key fields only | |
| let helpText = ''; | |
| if (key === 'gpu' || key === 'memory' || key === 'cpu') { | |
| helpText = '<div class="mt-1 p-2 bg-gradient-to-r from-blue-500/15 to-purple-500/15 rounded-lg border border-white/10"><p class="text-xs text-white/80 font-medium">e.g. 24GB, 8, 24GB..80GB</p></div>'; | |
| } else if (key === 'replicas') { | |
| helpText = '<div class="mt-1 p-2 bg-gradient-to-r from-green-500/15 to-blue-500/15 rounded-lg border border-white/10"><p class="text-xs text-white/80 font-medium">e.g. 1, 1..4, 2..10</p></div>'; | |
| } | |
| return ` | |
| <div class="mb-4 group"> | |
| <label for="${id}" class="flex items-center space-x-2 text-sm font-bold text-white capitalize mb-2 group-hover:text-white transition-colors"> | |
| <div class="text-white/80" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);">${fieldIcon}</div> | |
| <span style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); font-weight: 600;">${fieldLabel}</span> | |
| </label> | |
| ${inputHtml} | |
| ${helpText} | |
| </div>`; | |
| }; | |
| /** | |
| * Recursively builds the form UI from a JavaScript object. | |
| */ | |
| const renderForm = (data, parentElement, parentPath = '') => { | |
| if (Array.isArray(data)) { | |
| // --- Render Array --- | |
| const arrayContainer = document.createElement('div'); | |
| data.forEach((item, index) => { | |
| const itemPath = parentPath ? `${parentPath}.${index}` : String(index); | |
| const fieldset = document.createElement('fieldset'); | |
| fieldset.className = "glass border border-white/30 rounded-xl p-4 mb-4 relative group hover:glow transition-all duration-500 fieldset-enter"; | |
| const summaryText = generateSummary(item, itemPath); | |
| fieldset.innerHTML = ` | |
| <legend class="text-sm font-bold px-3 py-1.5 text-white bg-gradient-to-r from-violet-500/90 to-purple-500/90 rounded-lg shadow-lg backdrop-blur-md border border-white/30 cursor-pointer hover:from-violet-500 hover:to-purple-500 transition-all" style="text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);" onclick="toggleFieldset(this)"> | |
| <div class="flex items-center justify-between w-full"> | |
| <div class="flex items-center space-x-2"> | |
| <span>Item ${index + 1}</span> | |
| <svg class="w-4 h-4 collapse-arrow expanded" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M19 9l-7 7-7-7"></path> | |
| </svg> | |
| </div> | |
| </div> | |
| <div class="summary-preview" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);">${summaryText}</div> | |
| </legend> | |
| <button data-path="${itemPath}" class="remove-btn absolute top-2 right-2 p-1.5 text-white/60 hover:text-red-400 hover:bg-red-500/20 rounded-lg transition-all hover:scale-110"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg> | |
| </button> | |
| <div class="fieldset-content expanded mt-3"> | |
| </div> | |
| `; | |
| // Handle primitive values in arrays (like strings in commands array) | |
| const contentDiv = fieldset.querySelector('.fieldset-content'); | |
| if (typeof item !== 'object' || item === null) { | |
| contentDiv.innerHTML = createInputField('value', item, itemPath); | |
| } else { | |
| renderForm(item, contentDiv, itemPath); | |
| } | |
| arrayContainer.appendChild(fieldset); | |
| }); | |
| // Smart default for array items based on parent context | |
| let addButtonText = 'Add Item'; | |
| let newItemTemplate = "new_item"; | |
| if (parentPath === 'commands') { | |
| addButtonText = 'Add Command'; | |
| newItemTemplate = 'python script.py'; | |
| } else if (parentPath === 'env') { | |
| addButtonText = 'Add Environment Variable'; | |
| newItemTemplate = 'KEY=value'; | |
| } else if (parentPath === 'ports') { | |
| addButtonText = 'Add Port'; | |
| newItemTemplate = 8080; | |
| } else if (parentPath === 'rate_limits') { | |
| addButtonText = 'Add Rate Limit'; | |
| newItemTemplate = { prefix: "/api/", rps: 10 }; | |
| } | |
| arrayContainer.innerHTML += `<button data-path="${parentPath}" data-template='${JSON.stringify(newItemTemplate)}' class="add-btn mt-3 w-full flex items-center justify-center px-4 py-3 border-2 border-dashed border-white/30 text-sm font-bold rounded-xl text-white bg-gradient-to-r from-violet-500/20 to-purple-500/20 hover:from-violet-500/30 hover:to-purple-500/30 transition-all hover:scale-105 hover:shadow-lg backdrop-blur-sm"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-white/80" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>${addButtonText}</button>`; | |
| parentElement.appendChild(arrayContainer); | |
| } else if (typeof data === 'object' && data !== null) { | |
| // --- Render Object --- | |
| Object.entries(data).forEach(([key, value]) => { | |
| const currentPath = parentPath ? `${parentPath}.${key}` : key; | |
| if (typeof value === 'object' && value !== null) { | |
| // Check if this is a complex section that should use drawer editing | |
| const complexSections = ['commands', 'env', 'resources', 'model', 'rate_limits', 'ports']; | |
| const isComplexSection = complexSections.includes(key.toLowerCase()); | |
| if (isComplexSection) { | |
| // Create simple summary with edit button | |
| const summaryContainer = document.createElement('div'); | |
| summaryContainer.className = "mb-4 group"; | |
| const summaryText = generateSummary(value, currentPath); | |
| summaryContainer.innerHTML = ` | |
| <div class="flex items-center justify-between mb-2"> | |
| <label class="flex items-center space-x-2 text-sm font-bold text-white capitalize"> | |
| <div class="text-white/80" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> | |
| </svg> | |
| </div> | |
| <span style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); font-weight: 600;">${key.replace(/_/g, ' ')}</span> | |
| </label> | |
| <button class="edit-button" onclick="openDrawer('${currentPath}', '${key}')"> | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path> | |
| </svg> | |
| Edit | |
| </button> | |
| </div> | |
| <div class="glass border border-white/30 rounded-xl p-4"> | |
| <div class="text-sm text-white/70" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);"> | |
| ${summaryText || 'Click Edit to configure'} | |
| </div> | |
| </div> | |
| `; | |
| parentElement.appendChild(summaryContainer); | |
| } else { | |
| // Regular collapsible section for simple objects | |
| const groupContainer = document.createElement('div'); | |
| groupContainer.className = "mb-4 group"; | |
| const summaryText = generateSummary(value, currentPath); | |
| groupContainer.innerHTML = ` | |
| <div class="flex items-center space-x-2 text-sm font-bold text-white capitalize mb-2 group-hover:text-white transition-colors cursor-pointer" onclick="toggleFieldset(this)" style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); font-weight: 600;"> | |
| <svg class="w-4 h-4 collapse-arrow expanded" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M19 9l-7 7-7-7"></path> | |
| </svg> | |
| <span>${key.replace(/_/g, ' ')}</span> | |
| </div> | |
| <div class="summary-preview" style="display: none; font-size: 0.75rem; color: rgba(255, 255, 255, 0.7); margin-bottom: 0.5rem; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);">${summaryText}</div> | |
| <div class="fieldset-content expanded glass border border-white/30 rounded-xl p-4"> | |
| </div> | |
| `; | |
| const contentDiv = groupContainer.querySelector('.fieldset-content'); | |
| renderForm(value, contentDiv, currentPath); | |
| parentElement.appendChild(groupContainer); | |
| } | |
| } else { | |
| parentElement.innerHTML += createInputField(key, value, currentPath); | |
| } | |
| }); | |
| } | |
| }; | |
| // --- Update & Sync Functions --- | |
| const updateYamlEditor = () => { | |
| if (isUpdating) return; | |
| isUpdating = true; | |
| try { | |
| const yamlString = jsyaml.dump(currentData, { indent: 2 }); | |
| yamlEditor.setValue(yamlString); | |
| // Auto-collapse sections after setting content | |
| setTimeout(() => { | |
| autoCollapseAll(); | |
| }, 50); | |
| statusIndicator.textContent = 'Synced'; | |
| statusIndicator.className = 'status-indicator px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-full transition-all border border-white/20 bg-gradient-to-r from-green-500/20 to-emerald-500/20 text-green-300'; | |
| yamlEditorContainer.classList.remove('border-red-500'); | |
| } catch (e) { | |
| console.error("Error dumping YAML:", e); | |
| } | |
| setTimeout(() => { isUpdating = false; }, 50); | |
| }; | |
| const updateFormFromYaml = () => { | |
| if (isUpdating) return; | |
| isUpdating = true; | |
| try { | |
| const newData = jsyaml.load(yamlEditor.getValue()); | |
| if (JSON.stringify(newData) !== JSON.stringify(currentData)) { | |
| currentData = newData || {}; | |
| formContainer.innerHTML = ''; // Clear previous form | |
| // Hide/show empty state based on content | |
| const emptyState = document.getElementById('empty-state'); | |
| if (Object.keys(currentData).length === 0 || yamlEditor.getValue().trim() === '') { | |
| emptyState.style.display = 'flex'; | |
| formContainer.style.display = 'none'; | |
| } else { | |
| emptyState.style.display = 'none'; | |
| formContainer.style.display = 'block'; | |
| renderForm(currentData, formContainer); | |
| } | |
| } | |
| statusIndicator.textContent = 'Valid'; | |
| statusIndicator.className = 'status-indicator px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-full transition-all border border-white/20 bg-gradient-to-r from-green-500/20 to-emerald-500/20 text-green-300'; | |
| yamlEditorContainer.classList.remove('border-red-500'); | |
| } catch (e) { | |
| statusIndicator.textContent = 'Error'; | |
| statusIndicator.className = 'status-indicator px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-full transition-all border border-white/20 bg-gradient-to-r from-red-500/20 to-pink-500/20 text-red-300'; | |
| yamlEditorContainer.classList.add('border-red-500'); | |
| console.warn("Invalid YAML:", e.message); | |
| } | |
| setTimeout(() => { isUpdating = false; }, 50); | |
| }; | |
| const updateDataFromForm = (path, value) => { | |
| setNestedValue(currentData, path, value); | |
| updateYamlEditor(); | |
| }; | |
| // --- Event Listeners --- | |
| // Change event is now handled in the CodeMirror initialization above | |
| formContainer.addEventListener('input', (e) => { | |
| if (e.target.dataset.path) { | |
| const path = e.target.dataset.path; | |
| let value = e.target.value; | |
| if (e.target.type === 'checkbox') { | |
| value = e.target.checked; | |
| // Update label for checkbox | |
| const label = e.target.nextElementSibling; | |
| if (label) { | |
| label.textContent = value ? 'Enabled' : 'Disabled'; | |
| } | |
| } else if (e.target.type === 'number') { | |
| value = parseFloat(value) || 0; | |
| } | |
| updateDataFromForm(path, value); | |
| } | |
| }); | |
| formContainer.addEventListener('click', (e) => { | |
| const button = e.target.closest('button'); | |
| if (!button) return; | |
| const path = button.dataset.path; | |
| if (button.classList.contains('remove-btn')) { | |
| if (confirm('Are you sure you want to remove this item?')) { | |
| deleteNestedValue(currentData, path); | |
| formContainer.innerHTML = ''; | |
| renderForm(currentData, formContainer); | |
| updateYamlEditor(); | |
| } | |
| } else if (button.classList.contains('add-btn')) { | |
| let newItem; | |
| try { | |
| newItem = JSON.parse(button.dataset.template); | |
| } catch (e) { | |
| newItem = button.dataset.template; | |
| } | |
| const currentArray = path ? currentData[path] : currentData; | |
| const newIndex = Array.isArray(currentArray) ? currentArray.length : 0; | |
| setNestedValue(currentData, `${path ? path + '.' : ''}${newIndex}`, newItem); | |
| formContainer.innerHTML = ''; | |
| renderForm(currentData, formContainer); | |
| updateYamlEditor(); | |
| } | |
| }); | |
| const generateSummary = (data, path) => { | |
| if (Array.isArray(data)) { | |
| if (data.length === 0) return "Empty"; | |
| return `${data.length} item${data.length > 1 ? 's' : ''}`; | |
| } else if (typeof data === 'object' && data !== null) { | |
| const keys = Object.keys(data); | |
| if (keys.length === 0) return "Empty"; | |
| return `${keys.length} field${keys.length > 1 ? 's' : ''}: ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}`; | |
| } | |
| return ''; | |
| }; | |
| // --- Fast Collapsible Section Function --- | |
| window.toggleFieldset = function(headerElement) { | |
| const groupContainer = headerElement.closest('.group'); | |
| const content = groupContainer.querySelector('.fieldset-content'); | |
| const arrow = headerElement.querySelector('.collapse-arrow'); | |
| const summary = groupContainer.querySelector('.summary-preview'); | |
| const isExpanded = content.classList.contains('expanded'); | |
| if (isExpanded) { | |
| // Collapse - show summary, hide container | |
| content.classList.remove('expanded'); | |
| content.classList.add('collapsed'); | |
| arrow.classList.remove('expanded'); | |
| arrow.classList.add('collapsed'); | |
| if (summary) summary.style.display = 'block'; | |
| } else { | |
| // Expand - hide summary, show container | |
| content.classList.remove('collapsed'); | |
| content.classList.add('expanded'); | |
| arrow.classList.remove('collapsed'); | |
| arrow.classList.add('expanded'); | |
| if (summary) summary.style.display = 'none'; | |
| } | |
| }; | |
| // --- Preset Button Event Listeners --- | |
| document.getElementById('preset-open-r1').addEventListener('click', () => loadPresetConfig('open-r1')); | |
| document.getElementById('preset-model-serving').addEventListener('click', () => loadPresetConfig('model-serving')); | |
| document.getElementById('preset-jupyter-dev').addEventListener('click', () => loadPresetConfig('jupyter-dev')); | |
| document.getElementById('preset-data-processing').addEventListener('click', () => loadPresetConfig('data-processing')); | |
| document.getElementById('preset-web-app').addEventListener('click', () => loadPresetConfig('web-app')); | |
| document.getElementById('preset-gpu-cluster').addEventListener('click', () => loadPresetConfig('gpu-cluster')); | |
| // --- Annotation System --- | |
| let annotations = {}; // Store annotations by path | |
| window.toggleAnnotation = function(btn, event) { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| const path = btn.dataset.path; | |
| const popup = document.getElementById(`annotation-popup-${path}`); | |
| const isVisible = popup.classList.contains('show'); | |
| // Close all other popups | |
| document.querySelectorAll('.annotation-popup.show').forEach(p => { | |
| p.classList.remove('show'); | |
| }); | |
| if (!isVisible) { | |
| popup.classList.add('show'); | |
| const textarea = popup.querySelector('.annotation-input'); | |
| textarea.value = annotations[path] || ''; | |
| textarea.focus(); | |
| } | |
| }; | |
| window.saveAnnotation = function(path) { | |
| const popup = document.getElementById(`annotation-popup-${path}`); | |
| const textarea = popup.querySelector('.annotation-input'); | |
| const value = textarea.value.trim(); | |
| const btn = document.querySelector(`[data-path="${path}"].annotation-btn`); | |
| const display = document.getElementById(`annotation-display-${path}`); | |
| if (value) { | |
| annotations[path] = value; | |
| btn.classList.add('has-annotation'); | |
| display.textContent = value; | |
| display.style.display = 'block'; | |
| } else { | |
| delete annotations[path]; | |
| btn.classList.remove('has-annotation'); | |
| display.style.display = 'none'; | |
| } | |
| popup.classList.remove('show'); | |
| }; | |
| window.cancelAnnotation = function(path) { | |
| const popup = document.getElementById(`annotation-popup-${path}`); | |
| popup.classList.remove('show'); | |
| }; | |
| // Close annotation popups when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest('.annotation-container')) { | |
| document.querySelectorAll('.annotation-popup.show').forEach(p => { | |
| p.classList.remove('show'); | |
| }); | |
| } | |
| }); | |
| // --- Drawer Management --- | |
| let currentDrawerPath = null; | |
| let currentDrawerKey = null; | |
| let drawerData = null; | |
| window.openDrawer = function(path, key) { | |
| currentDrawerPath = path; | |
| currentDrawerKey = key; | |
| // Get the data for this section | |
| const keys = path.split('.'); | |
| let data = currentData; | |
| for (const k of keys) { | |
| if (data && typeof data === 'object') { | |
| data = data[k]; | |
| } | |
| } | |
| drawerData = JSON.parse(JSON.stringify(data || {})); // Deep clone | |
| // Update drawer content | |
| const drawerTitle = document.getElementById('drawer-title'); | |
| const drawerContent = document.getElementById('drawer-content'); | |
| drawerTitle.textContent = `Edit ${key.replace(/_/g, ' ')}`; | |
| // Simple drawer interface - just render the form | |
| drawerContent.innerHTML = ''; | |
| renderForm(drawerData, drawerContent, ''); | |
| // Auto-resize all textareas after rendering | |
| setTimeout(() => { | |
| drawerContent.querySelectorAll('textarea.auto-resize').forEach(textarea => { | |
| autoResizeTextarea(textarea); | |
| }); | |
| }, 50); | |
| // Show drawer | |
| const overlay = document.getElementById('drawer-overlay'); | |
| const drawer = document.getElementById('drawer'); | |
| overlay.classList.add('open'); | |
| drawer.classList.add('open'); | |
| }; | |
| window.closeDrawer = function() { | |
| const overlay = document.getElementById('drawer-overlay'); | |
| const drawer = document.getElementById('drawer'); | |
| overlay.classList.remove('open'); | |
| drawer.classList.remove('open'); | |
| currentDrawerPath = null; | |
| currentDrawerKey = null; | |
| drawerData = null; | |
| }; | |
| window.saveDrawer = function() { | |
| if (currentDrawerPath && drawerData) { | |
| // Update the main data | |
| setNestedValue(currentData, currentDrawerPath, drawerData); | |
| // Update YAML and form | |
| updateYamlEditor(); | |
| formContainer.innerHTML = ''; | |
| renderForm(currentData, formContainer); | |
| closeDrawer(); | |
| } | |
| }; | |
| // Auto-resize textarea function | |
| const autoResizeTextarea = (textarea) => { | |
| textarea.style.height = 'auto'; | |
| textarea.style.height = textarea.scrollHeight + 'px'; | |
| }; | |
| // Handle drawer form changes | |
| document.addEventListener('input', (e) => { | |
| if (e.target.closest('#drawer-content') && e.target.dataset.path) { | |
| const path = e.target.dataset.path; | |
| let value = e.target.value; | |
| if (e.target.type === 'checkbox') { | |
| value = e.target.checked; | |
| } else if (e.target.type === 'number') { | |
| value = parseFloat(value) || 0; | |
| } | |
| setNestedValue(drawerData, path, value); | |
| // Auto-resize textareas | |
| if (e.target.tagName === 'TEXTAREA' && e.target.classList.contains('auto-resize')) { | |
| autoResizeTextarea(e.target); | |
| } | |
| } | |
| }); | |
| // Handle drawer form clicks (add/remove buttons) | |
| document.addEventListener('click', (e) => { | |
| const button = e.target.closest('button'); | |
| if (!button || !button.closest('#drawer-content')) return; | |
| const path = button.dataset.path; | |
| if (button.classList.contains('remove-btn')) { | |
| if (confirm('Are you sure you want to remove this item?')) { | |
| deleteNestedValue(drawerData, path); | |
| const drawerContent = document.getElementById('drawer-content'); | |
| drawerContent.innerHTML = ''; | |
| renderForm(drawerData, drawerContent, ''); | |
| } | |
| } else if (button.classList.contains('add-btn')) { | |
| let newItem; | |
| try { | |
| newItem = JSON.parse(button.dataset.template); | |
| } catch (e) { | |
| newItem = button.dataset.template; | |
| } | |
| const currentArray = path ? drawerData[path] : drawerData; | |
| const newIndex = Array.isArray(currentArray) ? currentArray.length : 0; | |
| setNestedValue(drawerData, `${path ? path + '.' : ''}${newIndex}`, newItem); | |
| const drawerContent = document.getElementById('drawer-content'); | |
| drawerContent.innerHTML = ''; | |
| renderForm(drawerData, drawerContent, ''); | |
| } | |
| }); | |
| // --- Initial Load --- | |
| yamlEditor.setValue(dstackTemplates.task); | |
| updateFormFromYaml(); | |
| // Add arrows and auto-collapse after template is loaded | |
| setTimeout(() => { | |
| console.log('Template loaded, now adding arrows...'); | |
| addArrowsToContent(); | |
| setTimeout(() => { | |
| console.log('Auto-collapsing all sections...'); | |
| autoCollapseAll(); | |
| }, 100); | |
| }, 100); | |
| }); | |
| </script> | |
| </body> | |
| </html> |