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/[email protected]/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> |