bring back yahoo pipes!
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add pipes system

+1869 -84
+38 -11
crush.json
··· 1 1 { 2 - "$schema": "https://charm.land/crush.json", 3 - "lsp": { 4 - "biome": { 5 - "command": "bunx", 6 - "args": ["biome", "lsp-proxy"] 7 - }, 8 - "typescript": { 9 - "command": "bunx", 10 - "args": ["typescript-language-server", "--stdio"] 11 - } 12 - } 2 + "$schema": "https://charm.land/crush.json", 3 + "lsp": { 4 + "gopls": { 5 + "options": { 6 + "gofumpt": true, 7 + "codelenses": { 8 + "gc_details": true, 9 + "generate": true, 10 + "run_govulncheck": true, 11 + "test": true, 12 + "tidy": true, 13 + "upgrade_dependency": true 14 + }, 15 + "hints": { 16 + "assignVariableTypes": true, 17 + "compositeLiteralFields": true, 18 + "compositeLiteralTypes": true, 19 + "constantValues": true, 20 + "functionTypeParameters": true, 21 + "parameterNames": true, 22 + "rangeVariableTypes": true 23 + }, 24 + "analyses": { 25 + "nilness": true, 26 + "unusedparams": true, 27 + "unusedvariable": true, 28 + "unusedwrite": true, 29 + "useany": true 30 + }, 31 + "staticcheck": true, 32 + "directoryFilters": [ 33 + "-.git", 34 + "-node_modules" 35 + ], 36 + "semanticTokens": true 37 + } 38 + } 39 + } 13 40 }
+4 -1
engine/executor.go
··· 135 135 } 136 136 137 137 nodeResults[nodeID] = output 138 - e.db.LogExecution(executionID, nodeID, "info", fmt.Sprintf("Processed %d items", len(output))) 138 + 139 + // Log output data 140 + outputJSON, _ := json.Marshal(output) 141 + e.db.LogExecutionWithData(executionID, nodeID, "data", fmt.Sprintf("%d items", len(output)), string(outputJSON)) 139 142 } 140 143 141 144 // Return item count from last node
+8
engine/registry.go
··· 5 5 "sync" 6 6 7 7 "github.com/kierank/pipes/nodes" 8 + "github.com/kierank/pipes/nodes/outputs" 8 9 "github.com/kierank/pipes/nodes/sources" 9 10 "github.com/kierank/pipes/nodes/transforms" 10 11 ) ··· 20 21 } 21 22 22 23 // Register built-in nodes 24 + // Sources 23 25 r.Register(&sources.RSSSourceNode{}) 26 + 27 + // Transforms 24 28 r.Register(&transforms.FilterNode{}) 25 29 r.Register(&transforms.SortNode{}) 26 30 r.Register(&transforms.LimitNode{}) 31 + 32 + // Outputs 33 + r.Register(&outputs.JSONOutputNode{}) 34 + r.Register(&outputs.RSSOutputNode{}) 27 35 28 36 return r 29 37 }
+50
nodes/outputs/json.go
··· 1 + package outputs 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + 7 + "github.com/kierank/pipes/nodes" 8 + ) 9 + 10 + type JSONOutputNode struct{} 11 + 12 + func (n *JSONOutputNode) Type() string { return "json-output" } 13 + func (n *JSONOutputNode) Label() string { return "JSON Output" } 14 + func (n *JSONOutputNode) Description() string { return "Output data as JSON" } 15 + func (n *JSONOutputNode) Category() string { return "output" } 16 + func (n *JSONOutputNode) Inputs() int { return 1 } 17 + func (n *JSONOutputNode) Outputs() int { return 0 } 18 + 19 + func (n *JSONOutputNode) Execute(ctx context.Context, config map[string]interface{}, inputs [][]interface{}, execCtx *nodes.Context) ([]interface{}, error) { 20 + if len(inputs) == 0 || len(inputs[0]) == 0 { 21 + execCtx.Log("json-output", "info", "No input data") 22 + return nil, nil 23 + } 24 + 25 + data := inputs[0] 26 + 27 + // Pretty print JSON 28 + jsonData, err := json.MarshalIndent(map[string]interface{}{ 29 + "count": len(data), 30 + "items": data, 31 + }, "", " ") 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + execCtx.Log("json-output", "info", string(jsonData)) 37 + 38 + // Return the data (for potential chaining) 39 + return data, nil 40 + } 41 + 42 + func (n *JSONOutputNode) ValidateConfig(config map[string]interface{}) error { 43 + return nil 44 + } 45 + 46 + func (n *JSONOutputNode) GetConfigSchema() *nodes.ConfigSchema { 47 + return &nodes.ConfigSchema{ 48 + Fields: []nodes.ConfigField{}, 49 + } 50 + }
+142
nodes/outputs/rss.go
··· 1 + package outputs 2 + 3 + import ( 4 + "context" 5 + "encoding/xml" 6 + "fmt" 7 + 8 + "github.com/kierank/pipes/nodes" 9 + ) 10 + 11 + type RSSOutputNode struct{} 12 + 13 + func (n *RSSOutputNode) Type() string { return "rss-output" } 14 + func (n *RSSOutputNode) Label() string { return "RSS Output" } 15 + func (n *RSSOutputNode) Description() string { return "Output data as RSS feed" } 16 + func (n *RSSOutputNode) Category() string { return "output" } 17 + func (n *RSSOutputNode) Inputs() int { return 1 } 18 + func (n *RSSOutputNode) Outputs() int { return 0 } 19 + 20 + func (n *RSSOutputNode) Execute(ctx context.Context, config map[string]interface{}, inputs [][]interface{}, execCtx *nodes.Context) ([]interface{}, error) { 21 + if len(inputs) == 0 || len(inputs[0]) == 0 { 22 + execCtx.Log("rss-output", "info", "No input data") 23 + return nil, nil 24 + } 25 + 26 + data := inputs[0] 27 + 28 + // Get feed metadata from config 29 + feedTitle := getStringConfig(config, "title", "Pipes Feed") 30 + feedDescription := getStringConfig(config, "description", "Feed generated by Pipes") 31 + feedLink := getStringConfig(config, "link", "http://localhost:3001") 32 + 33 + // Build RSS feed 34 + type RSSItem struct { 35 + Title string `xml:"title"` 36 + Description string `xml:"description"` 37 + Link string `xml:"link"` 38 + PubDate string `xml:"pubDate,omitempty"` 39 + } 40 + 41 + type RSSChannel struct { 42 + Title string `xml:"title"` 43 + Description string `xml:"description"` 44 + Link string `xml:"link"` 45 + Items []RSSItem `xml:"item"` 46 + } 47 + 48 + type RSS struct { 49 + XMLName xml.Name `xml:"rss"` 50 + Version string `xml:"version,attr"` 51 + Channel RSSChannel `xml:"channel"` 52 + } 53 + 54 + var items []RSSItem 55 + for _, item := range data { 56 + itemMap, ok := item.(map[string]interface{}) 57 + if !ok { 58 + continue 59 + } 60 + 61 + rssItem := RSSItem{ 62 + Title: getStringFromMap(itemMap, "title", "Untitled"), 63 + Description: getStringFromMap(itemMap, "description", ""), 64 + Link: getStringFromMap(itemMap, "link", ""), 65 + } 66 + 67 + if pubDate, ok := itemMap["pubDate"].(string); ok { 68 + rssItem.PubDate = pubDate 69 + } 70 + 71 + items = append(items, rssItem) 72 + } 73 + 74 + feed := RSS{ 75 + Version: "2.0", 76 + Channel: RSSChannel{ 77 + Title: feedTitle, 78 + Description: feedDescription, 79 + Link: feedLink, 80 + Items: items, 81 + }, 82 + } 83 + 84 + xmlData, err := xml.MarshalIndent(feed, "", " ") 85 + if err != nil { 86 + return nil, fmt.Errorf("marshal RSS: %w", err) 87 + } 88 + 89 + rssOutput := fmt.Sprintf("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n%s", string(xmlData)) 90 + execCtx.Log("rss-output", "info", rssOutput) 91 + 92 + return data, nil 93 + } 94 + 95 + func (n *RSSOutputNode) ValidateConfig(config map[string]interface{}) error { 96 + return nil 97 + } 98 + 99 + func (n *RSSOutputNode) GetConfigSchema() *nodes.ConfigSchema { 100 + return &nodes.ConfigSchema{ 101 + Fields: []nodes.ConfigField{ 102 + { 103 + Name: "title", 104 + Label: "Feed Title", 105 + Type: "text", 106 + Required: false, 107 + DefaultValue: "Pipes Feed", 108 + HelpText: "Title of the RSS feed", 109 + }, 110 + { 111 + Name: "description", 112 + Label: "Feed Description", 113 + Type: "textarea", 114 + Required: false, 115 + DefaultValue: "Feed generated by Pipes", 116 + HelpText: "Description of the RSS feed", 117 + }, 118 + { 119 + Name: "link", 120 + Label: "Feed Link", 121 + Type: "url", 122 + Required: false, 123 + DefaultValue: "http://localhost:3001", 124 + HelpText: "URL of the feed", 125 + }, 126 + }, 127 + } 128 + } 129 + 130 + func getStringConfig(config map[string]interface{}, key, defaultValue string) string { 131 + if val, ok := config[key].(string); ok && val != "" { 132 + return val 133 + } 134 + return defaultValue 135 + } 136 + 137 + func getStringFromMap(m map[string]interface{}, key, defaultValue string) string { 138 + if val, ok := m[key].(string); ok { 139 + return val 140 + } 141 + return defaultValue 142 + }
+7 -1
nodes/sources/rss.go
··· 36 36 // Convert feed items to generic interface{} slices 37 37 var items []interface{} 38 38 for _, item := range feed.Items { 39 + // Flatten author field - extract name if it's a Person struct 40 + var author string 41 + if item.Author != nil { 42 + author = item.Author.Name 43 + } 44 + 39 45 items = append(items, map[string]interface{}{ 40 46 "title": item.Title, 41 47 "description": item.Description, 42 48 "link": item.Link, 43 - "author": item.Author, 49 + "author": author, 44 50 "published": item.Published, 45 51 "updated": item.Updated, 46 52 "guid": item.GUID,
+33 -17
store/executions.go
··· 9 9 ) 10 10 11 11 type PipeExecution struct { 12 - ID string 13 - PipeID string 14 - Status string 15 - TriggerType string 16 - StartedAt int64 17 - CompletedAt *int64 18 - DurationMs *int64 19 - ItemsProcessed *int 20 - ErrorMessage *string 21 - Metadata *string 12 + ID string `json:"id"` 13 + PipeID string `json:"pipe_id"` 14 + Status string `json:"status"` 15 + TriggerType string `json:"trigger_type"` 16 + StartedAt int64 `json:"started_at"` 17 + CompletedAt *int64 `json:"completed_at,omitempty"` 18 + DurationMs *int64 `json:"duration_ms,omitempty"` 19 + ItemsProcessed *int `json:"items_processed,omitempty"` 20 + ErrorMessage *string `json:"error_message,omitempty"` 21 + Metadata *string `json:"metadata,omitempty"` 22 22 } 23 23 24 24 type ExecutionLog struct { 25 - ID string 26 - ExecutionID string 27 - NodeID string 28 - Level string 29 - Message string 30 - Timestamp int64 31 - Metadata *string 25 + ID string `json:"id"` 26 + ExecutionID string `json:"execution_id"` 27 + NodeID string `json:"node_id"` 28 + Level string `json:"level"` 29 + Message string `json:"message"` 30 + Timestamp int64 `json:"timestamp"` 31 + Metadata *string `json:"metadata,omitempty"` 32 32 } 33 33 34 34 func (db *DB) CreateExecution(id, pipeID, triggerType string, startedAt int64) error { ··· 180 180 INSERT INTO execution_logs (id, execution_id, node_id, level, message, timestamp) 181 181 VALUES (?, ?, ?, ?, ?, ?) 182 182 `, logID, executionID, nodeID, level, message, timestamp) 183 + 184 + if err != nil { 185 + return fmt.Errorf("insert log: %w", err) 186 + } 187 + 188 + return nil 189 + } 190 + 191 + func (db *DB) LogExecutionWithData(executionID, nodeID, level, message, data string) error { 192 + logID := uuid.New().String() 193 + timestamp := time.Now().Unix() 194 + 195 + _, err := db.Exec(` 196 + INSERT INTO execution_logs (id, execution_id, node_id, level, message, timestamp, metadata) 197 + VALUES (?, ?, ?, ?, ?, ?, ?) 198 + `, logID, executionID, nodeID, level, message, timestamp, data) 183 199 184 200 if err != nil { 185 201 return fmt.Errorf("insert log: %w", err)
+8 -8
store/pipes.go
··· 9 9 ) 10 10 11 11 type Pipe struct { 12 - ID string 13 - UserID string 14 - Name string 15 - Description string 16 - Config string 17 - IsPublic bool 18 - CreatedAt int64 19 - UpdatedAt int64 12 + ID string `json:"id"` 13 + UserID string `json:"user_id"` 14 + Name string `json:"name"` 15 + Description string `json:"description"` 16 + Config string `json:"config"` 17 + IsPublic bool `json:"is_public"` 18 + CreatedAt int64 `json:"created_at"` 19 + UpdatedAt int64 `json:"updated_at"` 20 20 } 21 21 22 22 type ScheduledJob struct {
+177 -3
web/server.go
··· 6 6 "fmt" 7 7 "html/template" 8 8 "net/http" 9 + "strconv" 9 10 10 11 "github.com/charmbracelet/log" 11 12 "github.com/kierank/pipes/auth" ··· 65 66 mux.HandleFunc("/api/pipes", s.sessionManager.RequireAuth(s.handleAPIPipes)) 66 67 mux.HandleFunc("/api/pipes/", s.sessionManager.RequireAuth(s.handleAPIPipe)) 67 68 mux.HandleFunc("/api/node-types", s.handleAPINodeTypes) 69 + mux.HandleFunc("/api/executions/", s.sessionManager.RequireAuth(s.handleAPIExecution)) 68 70 69 71 s.server = &http.Server{ 70 72 Addr: fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port), ··· 170 172 } 171 173 172 174 func (s *Server) handlePipeEditor(w http.ResponseWriter, r *http.Request) { 173 - // TODO: Implement pipe editor 174 - w.Write([]byte("Pipe editor - coming soon!")) 175 + user := auth.GetUserFromContext(r.Context()) 176 + if user == nil { 177 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 178 + return 179 + } 180 + 181 + // Extract pipe ID from path 182 + pipeID := r.URL.Path[len("/pipes/"):] 183 + if len(pipeID) > 5 && pipeID[len(pipeID)-5:] == "/edit" { 184 + pipeID = pipeID[:len(pipeID)-5] 185 + } 186 + 187 + pipe, err := s.db.GetPipe(pipeID) 188 + if err != nil || pipe == nil { 189 + s.renderError(w, "Pipe Not Found", "The pipe you're looking for doesn't exist or has been deleted.", "") 190 + return 191 + } 192 + 193 + if pipe.UserID != user.ID { 194 + s.renderError(w, "Access Denied", "You don't have permission to access this pipe.", "") 195 + return 196 + } 197 + 198 + data := map[string]interface{}{ 199 + "User": user, 200 + "Pipe": pipe, 201 + } 202 + 203 + w.Header().Set("Content-Type", "text/html") 204 + s.templates.ExecuteTemplate(w, "editor.html", data) 175 205 } 176 206 177 207 func (s *Server) handleAPIMe(w http.ResponseWriter, r *http.Request) { ··· 242 272 } 243 273 244 274 // Extract pipe ID from path 245 - pipeID := r.URL.Path[len("/api/pipes/"):] 275 + path := r.URL.Path[len("/api/pipes/"):] 276 + 277 + // Check if it's an execute request 278 + if len(path) > 8 && path[len(path)-8:] == "/execute" { 279 + pipeID := path[:len(path)-8] 280 + s.handlePipeExecute(w, r, pipeID, user) 281 + return 282 + } 283 + 284 + // Check if it's an executions request 285 + if len(path) > 11 && path[len(path)-11:] == "/executions" { 286 + pipeID := path[:len(path)-11] 287 + s.handlePipeExecutions(w, r, pipeID, user) 288 + return 289 + } 290 + 291 + pipeID := path 246 292 247 293 switch r.Method { 248 294 case "GET": ··· 344 390 345 391 w.Header().Set("Content-Type", "application/json") 346 392 json.NewEncoder(w).Encode(nodeTypes) 393 + } 394 + 395 + func (s *Server) handlePipeExecute(w http.ResponseWriter, r *http.Request, pipeID string, user *store.User) { 396 + if r.Method != "POST" { 397 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 398 + return 399 + } 400 + 401 + pipe, err := s.db.GetPipe(pipeID) 402 + if err != nil || pipe == nil { 403 + http.Error(w, "Pipe not found", http.StatusNotFound) 404 + return 405 + } 406 + 407 + if pipe.UserID != user.ID { 408 + http.Error(w, "Forbidden", http.StatusForbidden) 409 + return 410 + } 411 + 412 + // Execute the pipe 413 + executor := engine.NewExecutor(s.db) 414 + executionID, err := executor.Execute(r.Context(), pipeID, "manual") 415 + if err != nil { 416 + s.logger.Error("pipe execution failed", "pipe_id", pipeID, "error", err) 417 + http.Error(w, fmt.Sprintf("Execution failed: %v", err), http.StatusInternalServerError) 418 + return 419 + } 420 + 421 + w.Header().Set("Content-Type", "application/json") 422 + json.NewEncoder(w).Encode(map[string]string{ 423 + "executionId": executionID, 424 + "status": "started", 425 + }) 426 + } 427 + 428 + func (s *Server) handlePipeExecutions(w http.ResponseWriter, r *http.Request, pipeID string, user *store.User) { 429 + if r.Method != "GET" { 430 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 431 + return 432 + } 433 + 434 + pipe, err := s.db.GetPipe(pipeID) 435 + if err != nil || pipe == nil { 436 + http.Error(w, "Pipe not found", http.StatusNotFound) 437 + return 438 + } 439 + 440 + if pipe.UserID != user.ID { 441 + http.Error(w, "Forbidden", http.StatusForbidden) 442 + return 443 + } 444 + 445 + // Get limit from query params 446 + limitStr := r.URL.Query().Get("limit") 447 + limit := 10 448 + if limitStr != "" { 449 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 450 + limit = l 451 + } 452 + } 453 + 454 + executions, err := s.db.GetPipeExecutions(pipeID, limit) 455 + if err != nil { 456 + s.logger.Error("failed to get executions", "pipe_id", pipeID, "error", err) 457 + http.Error(w, "Failed to get executions", http.StatusInternalServerError) 458 + return 459 + } 460 + 461 + w.Header().Set("Content-Type", "application/json") 462 + json.NewEncoder(w).Encode(executions) 463 + } 464 + 465 + func (s *Server) handleAPIExecution(w http.ResponseWriter, r *http.Request) { 466 + user := auth.GetUserFromContext(r.Context()) 467 + if user == nil { 468 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 469 + return 470 + } 471 + 472 + // Extract execution ID from path 473 + path := r.URL.Path[len("/api/executions/"):] 474 + 475 + // Check if it's a logs request 476 + if len(path) > 5 && path[len(path)-5:] == "/logs" { 477 + executionID := path[:len(path)-5] 478 + s.handleExecutionLogs(w, r, executionID, user) 479 + return 480 + } 481 + 482 + http.Error(w, "Not found", http.StatusNotFound) 483 + } 484 + 485 + func (s *Server) handleExecutionLogs(w http.ResponseWriter, r *http.Request, executionID string, user *store.User) { 486 + // Get the execution to check ownership 487 + exec, err := s.db.GetExecution(executionID) 488 + if err != nil { 489 + s.logger.Error("failed to get execution", "execution_id", executionID, "error", err) 490 + http.Error(w, "Failed to get execution", http.StatusInternalServerError) 491 + return 492 + } 493 + 494 + if exec == nil { 495 + http.Error(w, "Execution not found", http.StatusNotFound) 496 + return 497 + } 498 + 499 + // Verify user owns the pipe 500 + pipe, err := s.db.GetPipe(exec.PipeID) 501 + if err != nil || pipe == nil { 502 + http.Error(w, "Pipe not found", http.StatusNotFound) 503 + return 504 + } 505 + 506 + if pipe.UserID != user.ID { 507 + http.Error(w, "Forbidden", http.StatusForbidden) 508 + return 509 + } 510 + 511 + // Get logs 512 + logs, err := s.db.GetExecutionLogs(executionID) 513 + if err != nil { 514 + s.logger.Error("failed to get logs", "execution_id", executionID, "error", err) 515 + http.Error(w, "Failed to get logs", http.StatusInternalServerError) 516 + return 517 + } 518 + 519 + w.Header().Set("Content-Type", "application/json") 520 + json.NewEncoder(w).Encode(logs) 347 521 } 348 522 349 523 // Helper functions
+114 -2
web/templates/dashboard.html
··· 129 129 margin-bottom: 20px; 130 130 line-height: 1.5; 131 131 } 132 + .pipe-actions { 133 + display: flex; 134 + gap: 8px; 135 + margin-top: 12px; 136 + } 137 + .btn-danger { 138 + background: #dc2626; 139 + } 140 + .btn-danger:hover { 141 + background: #b91c1c; 142 + } 132 143 .empty-state { 133 144 text-align: center; 134 145 padding: 80px 20px; ··· 139 150 color: #666; 140 151 font-weight: 500; 141 152 } 153 + 154 + /* Toast Notifications */ 155 + #toast-container { 156 + position: fixed; 157 + top: 20px; 158 + right: 20px; 159 + z-index: 1000; 160 + display: flex; 161 + flex-direction: column; 162 + gap: 12px; 163 + pointer-events: none; 164 + } 165 + .toast { 166 + background: #fff; 167 + border: 3px solid #26242b; 168 + box-shadow: 4px 4px 0 #26242b; 169 + padding: 16px 20px; 170 + min-width: 280px; 171 + max-width: 400px; 172 + font-weight: 600; 173 + font-size: 14px; 174 + color: #26242b; 175 + pointer-events: all; 176 + animation: slideIn 0.2s ease; 177 + } 178 + .toast.success { 179 + border-left: 8px solid #2563eb; 180 + } 181 + .toast.error { 182 + border-left: 8px solid #dc2626; 183 + } 184 + .toast.info { 185 + border-left: 8px solid #ff6b35; 186 + } 187 + @keyframes slideIn { 188 + from { 189 + transform: translateX(calc(100% + 20px)); 190 + opacity: 0; 191 + } 192 + to { 193 + transform: translateX(0); 194 + opacity: 1; 195 + } 196 + } 197 + .toast.exit { 198 + animation: slideOut 0.2s ease forwards; 199 + } 200 + @keyframes slideOut { 201 + from { 202 + transform: translateX(0); 203 + opacity: 1; 204 + } 205 + to { 206 + transform: translateX(calc(100% + 20px)); 207 + opacity: 0; 208 + } 209 + } 142 210 </style> 143 211 </head> 144 212 <body> ··· 163 231 {{if .Description}} 164 232 <div class="pipe-desc">{{.Description}}</div> 165 233 {{end}} 166 - <a href="/pipes/{{.ID}}/edit" class="btn btn-secondary">Edit</a> 234 + <div class="pipe-actions"> 235 + <a href="/pipes/{{.ID}}/edit" class="btn btn-secondary">Edit</a> 236 + <button onclick="deletePipe('{{.ID}}', event)" class="btn btn-danger">Delete</button> 237 + </div> 167 238 </div> 168 239 {{end}} 169 240 </div> ··· 187 258 .then(pipe => { 188 259 window.location.href = '/pipes/' + pipe.id + '/edit'; 189 260 }) 190 - .catch(err => alert('Failed to create pipe: ' + err)); 261 + .catch(err => showToast('Failed to create pipe: ' + err, 'error')); 262 + } 263 + 264 + function deletePipe(pipeId, event) { 265 + event.stopPropagation(); 266 + if (!confirm('Are you sure you want to delete this pipe? This cannot be undone.')) { 267 + return; 268 + } 269 + 270 + fetch('/api/pipes/' + pipeId, { method: 'DELETE' }) 271 + .then(r => { 272 + if (!r.ok) throw new Error('Failed to delete'); 273 + return r.json(); 274 + }) 275 + .then(() => { 276 + showToast('Pipe deleted successfully', 'success'); 277 + setTimeout(() => window.location.reload(), 1000); 278 + }) 279 + .catch(err => { 280 + showToast('Failed to delete pipe: ' + err.message, 'error'); 281 + }); 282 + } 283 + 284 + // Toast notifications 285 + function showToast(message, type = 'info') { 286 + const container = document.getElementById('toast-container'); 287 + const toast = document.createElement('div'); 288 + toast.className = `toast ${type}`; 289 + toast.textContent = message; 290 + 291 + container.appendChild(toast); 292 + 293 + // Auto-dismiss after 3 seconds 294 + setTimeout(() => { 295 + toast.classList.add('exit'); 296 + setTimeout(() => { 297 + container.removeChild(toast); 298 + }, 200); // Match animation duration 299 + }, 3000); 191 300 } 192 301 </script> 302 + 303 + <!-- Toast Container --> 304 + <div id="toast-container"></div> 193 305 </body> 194 306 </html>
+1261
web/templates/editor.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>{{.Pipe.Name}} - Pipes</title> 7 + <link rel="icon" type="image/svg+xml" href="/public/favicon.svg"> 8 + <link rel="preconnect" href="https://fonts.googleapis.com"> 9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 10 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 11 + <style> 12 + * { margin: 0; padding: 0; box-sizing: border-box; } 13 + body { 14 + font-family: 'Space Grotesk', sans-serif; 15 + background: #f5f5f0; 16 + height: 100vh; 17 + overflow: hidden; 18 + display: flex; 19 + flex-direction: column; 20 + } 21 + 22 + /* Header */ 23 + header { 24 + background: #fff; 25 + border-bottom: 4px solid #26242b; 26 + padding: 16px 24px; 27 + display: flex; 28 + justify-content: space-between; 29 + align-items: center; 30 + box-shadow: 0 4px 0 #26242b; 31 + z-index: 100; 32 + } 33 + .pipe-name { 34 + color: #26242b; 35 + font-size: 24px; 36 + font-weight: 700; 37 + text-transform: uppercase; 38 + } 39 + .pipe-name .accent { 40 + color: #2563eb; 41 + } 42 + .header-actions { 43 + display: flex; 44 + gap: 12px; 45 + } 46 + .btn { 47 + display: inline-block; 48 + padding: 10px 20px; 49 + background: #ff6b35; 50 + color: #fff; 51 + border: 3px solid #26242b; 52 + font-size: 14px; 53 + font-weight: 700; 54 + text-decoration: none; 55 + font-family: 'Space Grotesk', sans-serif; 56 + text-transform: uppercase; 57 + letter-spacing: 0.05rem; 58 + box-shadow: 4px 4px 0 #26242b; 59 + cursor: pointer; 60 + transition: all 0.15s ease; 61 + } 62 + .btn:hover { 63 + transform: translate(2px, 2px); 64 + box-shadow: 2px 2px 0 #26242b; 65 + } 66 + .btn:active { 67 + transform: translate(4px, 4px); 68 + box-shadow: 0 0 0 #26242b; 69 + } 70 + .btn-secondary { 71 + background: #2563eb; 72 + } 73 + .btn-small { 74 + padding: 8px 16px; 75 + font-size: 12px; 76 + box-shadow: 3px 3px 0 #26242b; 77 + } 78 + 79 + /* Editor Layout */ 80 + .editor-container { 81 + display: flex; 82 + flex: 1; 83 + overflow: hidden; 84 + } 85 + 86 + /* Node Palette */ 87 + .node-palette { 88 + width: 280px; 89 + background: #fff; 90 + border-right: 4px solid #26242b; 91 + overflow-y: auto; 92 + padding: 20px; 93 + } 94 + .palette-title { 95 + color: #26242b; 96 + font-size: 16px; 97 + font-weight: 700; 98 + text-transform: uppercase; 99 + margin-bottom: 16px; 100 + } 101 + .node-category { 102 + margin-bottom: 24px; 103 + } 104 + .category-title { 105 + color: #666; 106 + font-size: 12px; 107 + font-weight: 700; 108 + text-transform: uppercase; 109 + margin-bottom: 8px; 110 + letter-spacing: 0.05em; 111 + } 112 + .palette-node { 113 + background: #f5f5f0; 114 + border: 3px solid #26242b; 115 + padding: 12px; 116 + margin-bottom: 8px; 117 + cursor: grab; 118 + transition: all 0.15s ease; 119 + } 120 + .palette-node:hover { 121 + background: #fff; 122 + transform: translateX(4px); 123 + } 124 + .palette-node:active { 125 + cursor: grabbing; 126 + } 127 + .palette-node-name { 128 + font-weight: 700; 129 + font-size: 14px; 130 + color: #26242b; 131 + margin-bottom: 4px; 132 + } 133 + .palette-node-desc { 134 + font-size: 11px; 135 + color: #666; 136 + line-height: 1.4; 137 + } 138 + 139 + /* Canvas */ 140 + .canvas-container { 141 + flex: 1; 142 + position: relative; 143 + overflow: hidden; 144 + } 145 + #canvas { 146 + position: absolute; 147 + top: 0; 148 + left: 0; 149 + pointer-events: none; 150 + } 151 + .node { 152 + position: absolute; 153 + background: #fff; 154 + border: 3px solid #26242b; 155 + min-width: 180px; 156 + box-shadow: 4px 4px 0 #26242b; 157 + cursor: move; 158 + z-index: 10; 159 + } 160 + .node.selected { 161 + border-color: #2563eb; 162 + box-shadow: 4px 4px 0 #2563eb; 163 + z-index: 20; 164 + } 165 + .node-header { 166 + background: #26242b; 167 + color: #fff; 168 + padding: 8px 12px; 169 + font-weight: 700; 170 + font-size: 13px; 171 + text-transform: uppercase; 172 + letter-spacing: 0.03em; 173 + } 174 + .node-body { 175 + padding: 12px; 176 + } 177 + .node-type { 178 + font-size: 11px; 179 + color: #666; 180 + margin-bottom: 8px; 181 + } 182 + .node-handle { 183 + position: absolute; 184 + width: 12px; 185 + height: 12px; 186 + background: #2563eb; 187 + border: 2px solid #26242b; 188 + cursor: crosshair; 189 + } 190 + .node-handle.input { 191 + left: -6px; 192 + top: 50%; 193 + transform: translateY(-50%); 194 + } 195 + .node-handle.output { 196 + right: -6px; 197 + top: 50%; 198 + transform: translateY(-50%); 199 + } 200 + .node-handle:hover { 201 + background: #ff6b35; 202 + width: 16px; 203 + height: 16px; 204 + } 205 + 206 + /* Config Panel */ 207 + .config-panel { 208 + width: 320px; 209 + background: #fff; 210 + border-left: 4px solid #26242b; 211 + overflow-y: auto; 212 + padding: 20px; 213 + display: none; 214 + } 215 + .config-panel.visible { 216 + display: block; 217 + } 218 + .config-title { 219 + color: #26242b; 220 + font-size: 16px; 221 + font-weight: 700; 222 + text-transform: uppercase; 223 + margin-bottom: 16px; 224 + } 225 + .form-group { 226 + margin-bottom: 16px; 227 + } 228 + .form-label { 229 + display: block; 230 + font-size: 12px; 231 + font-weight: 700; 232 + color: #26242b; 233 + text-transform: uppercase; 234 + margin-bottom: 6px; 235 + letter-spacing: 0.03em; 236 + } 237 + .form-input, .form-select, .form-textarea { 238 + width: 100%; 239 + padding: 10px; 240 + border: 3px solid #26242b; 241 + font-family: 'Space Grotesk', sans-serif; 242 + font-size: 14px; 243 + background: #f5f5f0; 244 + } 245 + .form-input:focus, .form-select:focus, .form-textarea:focus { 246 + outline: none; 247 + border-color: #2563eb; 248 + background: #fff; 249 + } 250 + .form-textarea { 251 + min-height: 80px; 252 + resize: vertical; 253 + } 254 + .form-help { 255 + font-size: 11px; 256 + color: #666; 257 + margin-top: 4px; 258 + } 259 + .delete-node-btn { 260 + background: #dc2626; 261 + width: 100%; 262 + margin-top: 20px; 263 + } 264 + .btn-view-output { 265 + background: #2563eb; 266 + width: 100%; 267 + margin-top: 8px; 268 + } 269 + .output-section { 270 + margin-top: 20px; 271 + padding-top: 20px; 272 + border-top: 3px solid #26242b; 273 + } 274 + .output-title { 275 + font-size: 12px; 276 + font-weight: 700; 277 + color: #26242b; 278 + text-transform: uppercase; 279 + margin-bottom: 8px; 280 + } 281 + .output-content { 282 + background: #f5f5f0; 283 + border: 3px solid #26242b; 284 + padding: 12px; 285 + font-size: 12px; 286 + max-height: 400px; 287 + overflow-y: auto; 288 + } 289 + .output-content pre { 290 + font-family: monospace; 291 + white-space: pre-wrap; 292 + word-break: break-all; 293 + } 294 + .output-content.rss-view { 295 + font-family: monospace; 296 + } 297 + .output-content.rss-view pre { 298 + margin: 0; 299 + white-space: pre-wrap; 300 + word-wrap: break-word; 301 + } 302 + .output-content.rss-view .key { 303 + color: #2563eb; 304 + font-weight: 700; 305 + } 306 + .output-empty { 307 + color: #666; 308 + font-style: italic; 309 + font-family: monospace; 310 + } 311 + 312 + /* Toast Notifications */ 313 + #toast-container { 314 + position: fixed; 315 + top: 80px; 316 + right: 20px; 317 + z-index: 1000; 318 + display: flex; 319 + flex-direction: column; 320 + gap: 12px; 321 + pointer-events: none; 322 + } 323 + .toast { 324 + background: #fff; 325 + border: 3px solid #26242b; 326 + box-shadow: 4px 4px 0 #26242b; 327 + padding: 16px 20px; 328 + min-width: 280px; 329 + max-width: 400px; 330 + font-weight: 600; 331 + font-size: 14px; 332 + color: #26242b; 333 + pointer-events: all; 334 + animation: slideIn 0.2s ease; 335 + } 336 + .toast.success { 337 + border-left: 8px solid #2563eb; 338 + } 339 + .toast.error { 340 + border-left: 8px solid #dc2626; 341 + } 342 + .toast.info { 343 + border-left: 8px solid #ff6b35; 344 + } 345 + @keyframes slideIn { 346 + from { 347 + transform: translateX(calc(100% + 20px)); 348 + opacity: 0; 349 + } 350 + to { 351 + transform: translateX(0); 352 + opacity: 1; 353 + } 354 + } 355 + .toast.exit { 356 + animation: slideOut 0.2s ease forwards; 357 + } 358 + @keyframes slideOut { 359 + from { 360 + transform: translateX(0); 361 + opacity: 1; 362 + } 363 + to { 364 + transform: translateX(calc(100% + 20px)); 365 + opacity: 0; 366 + } 367 + } 368 + </style> 369 + </head> 370 + <body> 371 + <header> 372 + <div class="pipe-name" onclick="renamePipe()" style="cursor: pointer;" title="Click to rename"> 373 + <span class="accent">{{.Pipe.Name}}</span> 374 + </div> 375 + <div class="header-actions"> 376 + <button onclick="executePipe()" class="btn btn-small">▶ Run</button> 377 + <button onclick="savePipe()" class="btn btn-small btn-secondary">💾 Save</button> 378 + <a href="/dashboard" class="btn btn-small" style="text-decoration: none;">← Back</a> 379 + </div> 380 + </header> 381 + 382 + <div class="editor-container"> 383 + <!-- Node Palette --> 384 + <div class="node-palette"> 385 + <div class="palette-title">Add Nodes</div> 386 + <div id="node-palette"></div> 387 + </div> 388 + 389 + <!-- Canvas --> 390 + <div class="canvas-container" id="canvas-container"> 391 + <canvas id="canvas"></canvas> 392 + <div id="nodes-container"></div> 393 + </div> 394 + 395 + <!-- Config Panel --> 396 + <div class="config-panel" id="config-panel"> 397 + <div class="config-title" id="config-node-name">Node Configuration</div> 398 + <div id="config-form"></div> 399 + </div> 400 + </div> 401 + 402 + <script> 403 + const pipeID = "{{.Pipe.ID}}"; 404 + let nodes = []; 405 + let connections = []; 406 + let selectedNode = null; 407 + let nodeTypes = []; 408 + let draggedNode = null; 409 + let dragOffset = { x: 0, y: 0 }; 410 + let connectionStart = null; 411 + let connectionDrag = null; 412 + let snapTarget = null; 413 + const SNAP_DISTANCE = 30; 414 + 415 + let canvas, ctx; 416 + let isPanning = false; 417 + let panStart = { x: 0, y: 0 }; 418 + let panOffset = { x: 0, y: 0 }; 419 + let spacePressed = false; 420 + 421 + // Utility function for HTML escaping 422 + function escapeHtml(text) { 423 + if (text == null) return ''; 424 + return String(text) 425 + .replace(/&/g, '&amp;') 426 + .replace(/</g, '&lt;') 427 + .replace(/>/g, '&gt;') 428 + .replace(/"/g, '&quot;') 429 + .replace(/'/g, '&#039;'); 430 + } 431 + 432 + // Initialize 433 + document.addEventListener('DOMContentLoaded', async () => { 434 + canvas = document.getElementById('canvas'); 435 + ctx = canvas.getContext('2d'); 436 + resizeCanvas(); 437 + window.addEventListener('resize', resizeCanvas); 438 + 439 + await loadNodeTypes(); 440 + await loadPipe(); 441 + renderPalette(); 442 + render(); 443 + 444 + // Canvas interactions 445 + const container = document.getElementById('canvas-container'); 446 + container.addEventListener('click', (e) => { 447 + if (e.target === container || e.target === canvas) { 448 + deselectNode(); 449 + } 450 + }); 451 + 452 + // Pan controls 453 + document.addEventListener('keydown', (e) => { 454 + if (e.code === 'Space' && !spacePressed) { 455 + spacePressed = true; 456 + container.style.cursor = 'grab'; 457 + } 458 + }); 459 + 460 + document.addEventListener('keyup', (e) => { 461 + if (e.code === 'Space') { 462 + spacePressed = false; 463 + if (!isPanning) container.style.cursor = ''; 464 + } 465 + }); 466 + 467 + container.addEventListener('mousedown', (e) => { 468 + if (e.button === 1 || (e.button === 0 && spacePressed)) { 469 + e.preventDefault(); 470 + isPanning = true; 471 + panStart = { x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }; 472 + container.style.cursor = 'grabbing'; 473 + } 474 + }); 475 + 476 + document.addEventListener('mousemove', (e) => { 477 + if (isPanning) { 478 + panOffset.x = e.clientX - panStart.x; 479 + panOffset.y = e.clientY - panStart.y; 480 + updatePanTransform(); 481 + } 482 + }); 483 + 484 + document.addEventListener('mouseup', (e) => { 485 + if (e.button === 1 || e.button === 0) { 486 + if (isPanning) { 487 + isPanning = false; 488 + container.style.cursor = spacePressed ? 'grab' : ''; 489 + } 490 + } 491 + }); 492 + 493 + // Prevent middle mouse scroll 494 + container.addEventListener('mousedown', (e) => { 495 + if (e.button === 1) e.preventDefault(); 496 + }); 497 + }); 498 + 499 + function resizeCanvas() { 500 + const container = document.getElementById('canvas-container'); 501 + const dpr = window.devicePixelRatio || 1; 502 + 503 + canvas.width = container.clientWidth * dpr; 504 + canvas.height = container.clientHeight * dpr; 505 + canvas.style.width = container.clientWidth + 'px'; 506 + canvas.style.height = container.clientHeight + 'px'; 507 + 508 + render(); 509 + } 510 + 511 + function updatePanTransform() { 512 + const nodesContainer = document.getElementById('nodes-container'); 513 + nodesContainer.style.transform = `translate(${panOffset.x}px, ${panOffset.y}px)`; 514 + render(); 515 + } 516 + 517 + async function loadNodeTypes() { 518 + const res = await fetch('/api/node-types'); 519 + nodeTypes = await res.json(); 520 + } 521 + 522 + async function loadPipe() { 523 + const res = await fetch(`/api/pipes/${pipeID}`); 524 + const pipe = await res.json(); 525 + console.log('Loaded pipe:', pipe); 526 + console.log('Config type:', typeof pipe.config); 527 + console.log('Config value:', pipe.config); 528 + if (pipe.config) { 529 + const config = typeof pipe.config === 'string' ? JSON.parse(pipe.config) : pipe.config; 530 + console.log('Parsed config:', config); 531 + nodes = config.nodes || []; 532 + connections = config.connections || []; 533 + console.log('Loaded nodes:', nodes); 534 + console.log('Loaded connections:', connections); 535 + } 536 + } 537 + 538 + function renderPalette() { 539 + const palette = document.getElementById('node-palette'); 540 + const categories = {}; 541 + 542 + nodeTypes.forEach(type => { 543 + if (!categories[type.category]) { 544 + categories[type.category] = []; 545 + } 546 + categories[type.category].push(type); 547 + }); 548 + 549 + Object.keys(categories).sort().forEach(category => { 550 + const categoryDiv = document.createElement('div'); 551 + categoryDiv.className = 'node-category'; 552 + 553 + const title = document.createElement('div'); 554 + title.className = 'category-title'; 555 + title.textContent = category; 556 + categoryDiv.appendChild(title); 557 + 558 + categories[category].forEach(type => { 559 + const node = document.createElement('div'); 560 + node.className = 'palette-node'; 561 + node.draggable = true; 562 + node.dataset.type = type.type; 563 + 564 + const name = document.createElement('div'); 565 + name.className = 'palette-node-name'; 566 + name.textContent = type.label; 567 + node.appendChild(name); 568 + 569 + const desc = document.createElement('div'); 570 + desc.className = 'palette-node-desc'; 571 + desc.textContent = type.description; 572 + node.appendChild(desc); 573 + 574 + node.addEventListener('dragstart', onPaletteDragStart); 575 + categoryDiv.appendChild(node); 576 + }); 577 + 578 + palette.appendChild(categoryDiv); 579 + }); 580 + } 581 + 582 + function onPaletteDragStart(e) { 583 + e.dataTransfer.setData('nodeType', e.currentTarget.dataset.type); 584 + } 585 + 586 + // Setup drop zone 587 + const container = document.getElementById('canvas-container'); 588 + container.addEventListener('dragover', (e) => e.preventDefault()); 589 + container.addEventListener('drop', (e) => { 590 + e.preventDefault(); 591 + const nodeType = e.dataTransfer.getData('nodeType'); 592 + if (!nodeType) return; 593 + 594 + const rect = container.getBoundingClientRect(); 595 + const x = e.clientX - rect.left - panOffset.x; 596 + const y = e.clientY - rect.top - panOffset.y; 597 + 598 + addNode(nodeType, x, y); 599 + }); 600 + 601 + function addNode(type, x, y) { 602 + const nodeType = nodeTypes.find(t => t.type === type); 603 + if (!nodeType) return; 604 + 605 + const node = { 606 + id: generateID(), 607 + type: type, 608 + label: nodeType.label, 609 + position: { x, y }, 610 + config: {} 611 + }; 612 + 613 + nodes.push(node); 614 + render(); 615 + } 616 + 617 + function render() { 618 + renderNodes(); 619 + // Use requestAnimationFrame to ensure nodes are in DOM before drawing connections 620 + requestAnimationFrame(() => { 621 + renderConnections(); 622 + }); 623 + } 624 + 625 + function renderConnections() { 626 + const dpr = window.devicePixelRatio || 1; 627 + 628 + // Reset transform and clear canvas 629 + ctx.setTransform(1, 0, 0, 1, 0, 0); 630 + ctx.clearRect(0, 0, canvas.width, canvas.height); 631 + 632 + // Apply DPR scaling and pan offset 633 + ctx.setTransform(dpr, 0, 0, dpr, panOffset.x * dpr, panOffset.y * dpr); 634 + 635 + ctx.strokeStyle = '#26242b'; 636 + ctx.lineWidth = 3; 637 + 638 + connections.forEach(conn => { 639 + const source = nodes.find(n => n.id === conn.source); 640 + const target = nodes.find(n => n.id === conn.target); 641 + if (!source || !target) return; 642 + 643 + const sourceEl = document.getElementById(`node-${source.id}`); 644 + const targetEl = document.getElementById(`node-${target.id}`); 645 + if (!sourceEl || !targetEl) return; 646 + 647 + // Use actual dimensions from DOM elements 648 + const sourceWidth = sourceEl.offsetWidth; 649 + const sourceHeight = sourceEl.offsetHeight; 650 + const targetWidth = targetEl.offsetWidth; 651 + const targetHeight = targetEl.offsetHeight; 652 + 653 + // Calculate positions from node data (already in local coordinates) 654 + const x1 = source.position.x + sourceWidth; 655 + const y1 = source.position.y + sourceHeight / 2; 656 + const x2 = target.position.x; 657 + const y2 = target.position.y + targetHeight / 2; 658 + 659 + // Create path for hit detection 660 + const path = new Path2D(); 661 + path.moveTo(x1, y1); 662 + const mx = (x1 + x2) / 2; 663 + path.bezierCurveTo(mx, y1, mx, y2, x2, y2); 664 + 665 + // Store path for click detection 666 + conn._path = path; 667 + conn._transform = { dpr, panOffset }; 668 + conn._midpoint = { x: mx, y: (y1 + y2) / 2 }; 669 + 670 + // Draw connection with deletion animation 671 + ctx.beginPath(); 672 + ctx.moveTo(x1, y1); 673 + ctx.bezierCurveTo(mx, y1, mx, y2, x2, y2); 674 + 675 + // Apply deletion animation 676 + if (conn._deleting) { 677 + const elapsed = Date.now() - conn._deleteTime; 678 + const progress = Math.min(elapsed / 200, 1); 679 + const alpha = 1 - progress; 680 + 681 + ctx.globalAlpha = alpha; 682 + ctx.strokeStyle = '#dc2626'; // Red color for deletion 683 + ctx.lineWidth = 5; 684 + ctx.setLineDash([10, 5]); 685 + } else { 686 + ctx.strokeStyle = '#26242b'; 687 + ctx.lineWidth = 3; 688 + } 689 + 690 + ctx.stroke(); 691 + ctx.globalAlpha = 1; 692 + ctx.setLineDash([]); 693 + }); 694 + 695 + // Draw temporary connection while dragging 696 + if (connectionStart && connectionDrag) { 697 + const source = nodes.find(n => n.id === connectionStart.nodeID); 698 + if (source) { 699 + const sourceEl = document.getElementById(`node-${source.id}`); 700 + if (sourceEl) { 701 + const sourceWidth = sourceEl.offsetWidth; 702 + const sourceHeight = sourceEl.offsetHeight; 703 + 704 + const x1 = source.position.x + sourceWidth; 705 + const y1 = source.position.y + sourceHeight / 2; 706 + 707 + let x2 = connectionDrag.x; 708 + let y2 = connectionDrag.y; 709 + 710 + // Snap to target if close enough 711 + if (snapTarget) { 712 + x2 = snapTarget.x; 713 + y2 = snapTarget.y; 714 + 715 + // Draw snap indicator 716 + ctx.save(); 717 + ctx.fillStyle = '#2563eb'; 718 + ctx.globalAlpha = 0.3; 719 + ctx.beginPath(); 720 + ctx.arc(x2, y2, 20, 0, Math.PI * 2); 721 + ctx.fill(); 722 + ctx.restore(); 723 + } 724 + 725 + // Draw temporary connection line 726 + ctx.save(); 727 + ctx.strokeStyle = snapTarget ? '#2563eb' : '#666'; 728 + ctx.lineWidth = 2; 729 + ctx.setLineDash([5, 5]); 730 + ctx.beginPath(); 731 + ctx.moveTo(x1, y1); 732 + const mx = (x1 + x2) / 2; 733 + ctx.bezierCurveTo(mx, y1, mx, y2, x2, y2); 734 + ctx.stroke(); 735 + ctx.restore(); 736 + } 737 + } 738 + } 739 + } 740 + 741 + function renderNodes() { 742 + const container = document.getElementById('nodes-container'); 743 + container.innerHTML = ''; 744 + 745 + nodes.forEach(node => { 746 + const el = document.createElement('div'); 747 + el.id = `node-${node.id}`; 748 + el.className = 'node'; 749 + if (selectedNode === node.id) el.classList.add('selected'); 750 + el.style.left = `${node.position.x}px`; 751 + el.style.top = `${node.position.y}px`; 752 + 753 + const header = document.createElement('div'); 754 + header.className = 'node-header'; 755 + header.textContent = node.label; 756 + el.appendChild(header); 757 + 758 + const body = document.createElement('div'); 759 + body.className = 'node-body'; 760 + 761 + const type = document.createElement('div'); 762 + type.className = 'node-type'; 763 + type.textContent = node.type; 764 + body.appendChild(type); 765 + 766 + el.appendChild(body); 767 + 768 + // Handles 769 + const nodeType = nodeTypes.find(t => t.type === node.type); 770 + if (nodeType && nodeType.category !== 'source') { 771 + const inputHandle = document.createElement('div'); 772 + inputHandle.className = 'node-handle input'; 773 + inputHandle.addEventListener('mousedown', (e) => onHandleMouseDown(e, node.id, 'input')); 774 + el.appendChild(inputHandle); 775 + } 776 + 777 + if (nodeType && nodeType.category !== 'output') { 778 + const outputHandle = document.createElement('div'); 779 + outputHandle.className = 'node-handle output'; 780 + outputHandle.addEventListener('mousedown', (e) => onHandleMouseDown(e, node.id, 'output')); 781 + el.appendChild(outputHandle); 782 + } 783 + 784 + el.addEventListener('mousedown', (e) => onNodeMouseDown(e, node.id)); 785 + el.addEventListener('click', (e) => { 786 + e.stopPropagation(); 787 + selectNode(node.id); 788 + }); 789 + 790 + container.appendChild(el); 791 + }); 792 + } 793 + 794 + function onNodeMouseDown(e, nodeID) { 795 + if (e.target.classList.contains('node-handle')) return; 796 + 797 + const node = nodes.find(n => n.id === nodeID); 798 + draggedNode = node; 799 + 800 + const nodeEl = document.getElementById(`node-${nodeID}`); 801 + const rect = nodeEl.getBoundingClientRect(); 802 + const containerRect = document.getElementById('canvas-container').getBoundingClientRect(); 803 + 804 + dragOffset = { 805 + x: e.clientX - rect.left, 806 + y: e.clientY - rect.top 807 + }; 808 + 809 + document.addEventListener('mousemove', onNodeMouseMove); 810 + document.addEventListener('mouseup', onNodeMouseUp); 811 + } 812 + 813 + function onNodeMouseMove(e) { 814 + if (!draggedNode) return; 815 + 816 + const containerRect = document.getElementById('canvas-container').getBoundingClientRect(); 817 + draggedNode.position.x = e.clientX - containerRect.left - dragOffset.x; 818 + draggedNode.position.y = e.clientY - containerRect.top - dragOffset.y; 819 + 820 + render(); 821 + } 822 + 823 + function onNodeMouseUp() { 824 + draggedNode = null; 825 + document.removeEventListener('mousemove', onNodeMouseMove); 826 + document.removeEventListener('mouseup', onNodeMouseUp); 827 + } 828 + 829 + function onHandleMouseDown(e, nodeID, handleType) { 830 + e.stopPropagation(); 831 + e.preventDefault(); 832 + 833 + if (handleType === 'output') { 834 + // Check if there's an existing connection to delete 835 + const existing = connections.filter(c => c.source === nodeID); 836 + if (existing.length > 0 && e.shiftKey) { 837 + // Shift+click to delete outgoing connections 838 + existing.forEach(conn => { 839 + const index = connections.indexOf(conn); 840 + if (index !== -1) connections.splice(index, 1); 841 + }); 842 + render(); 843 + showToast(`Deleted ${existing.length} connection${existing.length > 1 ? 's' : ''}`, 'info'); 844 + return; 845 + } 846 + 847 + connectionStart = { nodeID, handleType }; 848 + document.addEventListener('mousemove', onConnectionMouseMove); 849 + document.addEventListener('mouseup', onConnectionMouseUp); 850 + } else { 851 + // Input handle - delete incoming connections 852 + const existing = connections.filter(c => c.target === nodeID); 853 + if (existing.length > 0) { 854 + existing.forEach(conn => { 855 + const index = connections.indexOf(conn); 856 + if (index !== -1) connections.splice(index, 1); 857 + }); 858 + render(); 859 + showToast(`Deleted ${existing.length} connection${existing.length > 1 ? 's' : ''}`, 'info'); 860 + } 861 + } 862 + } 863 + 864 + function onConnectionMouseMove(e) { 865 + const containerRect = document.getElementById('canvas-container').getBoundingClientRect(); 866 + const mouseX = e.clientX - containerRect.left; 867 + const mouseY = e.clientY - containerRect.top; 868 + 869 + connectionDrag = { x: mouseX, y: mouseY }; 870 + 871 + // Check for snap targets 872 + snapTarget = null; 873 + let minDist = SNAP_DISTANCE; 874 + 875 + nodes.forEach(node => { 876 + if (node.id === connectionStart.nodeID) return; 877 + const nodeType = nodeTypes.find(t => t.type === node.type); 878 + if (!nodeType || nodeType.category === 'source') return; 879 + 880 + // Calculate input handle position using actual node dimensions 881 + const nodeEl = document.getElementById(`node-${node.id}`); 882 + if (!nodeEl) return; 883 + 884 + const handleX = node.position.x; 885 + const handleY = node.position.y + nodeEl.offsetHeight / 2; 886 + 887 + const dist = Math.sqrt((mouseX - handleX) ** 2 + (mouseY - handleY) ** 2); 888 + if (dist < minDist) { 889 + minDist = dist; 890 + snapTarget = { nodeID: node.id, x: handleX, y: handleY }; 891 + } 892 + }); 893 + 894 + render(); 895 + } 896 + 897 + function onConnectionMouseUp(e) { 898 + if (!connectionStart) return; 899 + 900 + let targetID = null; 901 + 902 + // Use snap target if available 903 + if (snapTarget) { 904 + targetID = snapTarget.nodeID; 905 + } else { 906 + // Fallback to exact handle hit detection 907 + const target = e.target; 908 + if (target.classList.contains('node-handle') && target.classList.contains('input')) { 909 + const targetNode = target.closest('.node'); 910 + targetID = targetNode.id.replace('node-', ''); 911 + } 912 + } 913 + 914 + // Create connection if valid and not duplicate 915 + if (targetID && targetID !== connectionStart.nodeID) { 916 + const exists = connections.some(c => c.source === connectionStart.nodeID && c.target === targetID); 917 + if (!exists) { 918 + connections.push({ 919 + id: generateID(), 920 + source: connectionStart.nodeID, 921 + target: targetID 922 + }); 923 + } 924 + } 925 + 926 + connectionStart = null; 927 + connectionDrag = null; 928 + snapTarget = null; 929 + document.removeEventListener('mousemove', onConnectionMouseMove); 930 + document.removeEventListener('mouseup', onConnectionMouseUp); 931 + render(); 932 + } 933 + 934 + function selectNode(nodeID) { 935 + selectedNode = nodeID; 936 + render(); 937 + showConfigPanel(nodeID); 938 + viewNodeData(nodeID); 939 + } 940 + 941 + function deselectNode() { 942 + selectedNode = null; 943 + render(); 944 + document.getElementById('config-panel').classList.remove('visible'); 945 + } 946 + 947 + function showConfigPanel(nodeID) { 948 + const node = nodes.find(n => n.id === nodeID); 949 + const nodeType = nodeTypes.find(t => t.type === node.type); 950 + 951 + document.getElementById('config-node-name').textContent = node.label; 952 + document.getElementById('config-panel').classList.add('visible'); 953 + 954 + const form = document.getElementById('config-form'); 955 + form.innerHTML = ''; 956 + 957 + if (!nodeType.schema || !nodeType.schema.fields) { 958 + form.innerHTML = '<p style="color: #666;">No configuration needed</p>'; 959 + return; 960 + } 961 + 962 + nodeType.schema.fields.forEach(field => { 963 + const group = document.createElement('div'); 964 + group.className = 'form-group'; 965 + 966 + const label = document.createElement('label'); 967 + label.className = 'form-label'; 968 + label.textContent = field.label; 969 + group.appendChild(label); 970 + 971 + let input; 972 + if (field.type === 'select') { 973 + input = document.createElement('select'); 974 + input.className = 'form-select'; 975 + field.options.forEach(opt => { 976 + const option = document.createElement('option'); 977 + option.value = opt.value; 978 + option.textContent = opt.label; 979 + input.appendChild(option); 980 + }); 981 + } else if (field.type === 'textarea') { 982 + input = document.createElement('textarea'); 983 + input.className = 'form-textarea'; 984 + } else { 985 + input = document.createElement('input'); 986 + input.className = 'form-input'; 987 + input.type = field.type === 'number' ? 'number' : 'text'; 988 + } 989 + 990 + input.value = node.config[field.name] || field.defaultValue || ''; 991 + input.addEventListener('change', (e) => { 992 + node.config[field.name] = e.target.value; 993 + }); 994 + 995 + group.appendChild(input); 996 + 997 + if (field.helpText) { 998 + const help = document.createElement('div'); 999 + help.className = 'form-help'; 1000 + help.textContent = field.helpText; 1001 + group.appendChild(help); 1002 + } 1003 + 1004 + form.appendChild(group); 1005 + }); 1006 + 1007 + // Data display section (always visible) 1008 + const dataSection = document.createElement('div'); 1009 + dataSection.className = 'output-section'; 1010 + dataSection.style.marginTop = '20px'; 1011 + 1012 + const dataTitle = document.createElement('div'); 1013 + dataTitle.className = 'output-title'; 1014 + dataTitle.textContent = 'Node Data'; 1015 + dataSection.appendChild(dataTitle); 1016 + 1017 + const dataContent = document.createElement('div'); 1018 + dataContent.className = 'output-content output-empty'; 1019 + dataContent.id = `data-content-${nodeID}`; 1020 + dataContent.textContent = 'Run the pipe to see data'; 1021 + dataSection.appendChild(dataContent); 1022 + 1023 + form.appendChild(dataSection); 1024 + 1025 + const deleteBtn = document.createElement('button'); 1026 + deleteBtn.className = 'btn delete-node-btn'; 1027 + deleteBtn.textContent = '🗑 Delete Node'; 1028 + deleteBtn.style.marginTop = '20px'; 1029 + deleteBtn.onclick = () => deleteNode(nodeID); 1030 + form.appendChild(deleteBtn); 1031 + } 1032 + 1033 + function deleteNode(nodeID) { 1034 + nodes = nodes.filter(n => n.id !== nodeID); 1035 + connections = connections.filter(c => c.source !== nodeID && c.target !== nodeID); 1036 + deselectNode(); 1037 + render(); 1038 + } 1039 + 1040 + async function renamePipe() { 1041 + const newName = prompt('Enter new pipe name:', document.querySelector('.pipe-name .accent').textContent); 1042 + if (!newName || newName.trim() === '') return; 1043 + 1044 + const res = await fetch(`/api/pipes/${pipeID}`, { 1045 + method: 'PUT', 1046 + headers: { 'Content-Type': 'application/json' }, 1047 + body: JSON.stringify({ name: newName.trim() }) 1048 + }); 1049 + 1050 + if (res.ok) { 1051 + document.querySelector('.pipe-name .accent').textContent = newName.trim(); 1052 + document.title = `${newName.trim()} - Pipes`; 1053 + showToast('Pipe renamed', 'success'); 1054 + } else { 1055 + showToast('Failed to rename pipe', 'error'); 1056 + } 1057 + } 1058 + 1059 + let executionStatusInterval = null; 1060 + 1061 + async function viewNodeData(nodeID) { 1062 + const dataContent = document.getElementById(`data-content-${nodeID}`); 1063 + if (!dataContent) return; 1064 + 1065 + dataContent.className = 'output-content output-empty'; 1066 + dataContent.textContent = 'Loading...'; 1067 + 1068 + try { 1069 + // Get latest execution 1070 + const execRes = await fetch(`/api/pipes/${pipeID}/executions?limit=1`); 1071 + if (!execRes.ok) throw new Error('No executions found'); 1072 + 1073 + const executions = await execRes.json(); 1074 + if (!executions || executions.length === 0) { 1075 + dataContent.textContent = 'No executions yet. Run the pipe first.'; 1076 + return; 1077 + } 1078 + 1079 + const execution = executions[0]; 1080 + const executionId = execution.id; 1081 + 1082 + // Get logs for this node 1083 + const logsRes = await fetch(`/api/executions/${executionId}/logs`); 1084 + if (!logsRes.ok) throw new Error('Failed to fetch logs'); 1085 + 1086 + const logs = await logsRes.json(); 1087 + const dataLog = logs.find(log => log.node_id === nodeID && log.level === 'data'); 1088 + 1089 + if (!dataLog || !dataLog.metadata) { 1090 + dataContent.textContent = 'No data available for this node.'; 1091 + return; 1092 + } 1093 + 1094 + // Parse and display the data 1095 + const data = JSON.parse(dataLog.metadata); 1096 + dataContent.className = 'output-content'; 1097 + 1098 + // Format data based on node type 1099 + const node = nodes.find(n => n.id === nodeID); 1100 + if (node && node.type === 'rss-source' && Array.isArray(data)) { 1101 + // Format RSS items with colored field names 1102 + dataContent.classList.add('rss-view'); 1103 + let html = '<pre>'; 1104 + data.forEach((item, idx) => { 1105 + html += `<span class="key">title:</span> ${escapeHtml(item.title || '')}\n`; 1106 + if (item.link) { 1107 + html += `<span class="key">link:</span> ${escapeHtml(item.link)}\n`; 1108 + } 1109 + if (item.author) { 1110 + html += `<span class="key">author:</span> ${escapeHtml(item.author)}\n`; 1111 + } 1112 + if (item.published) { 1113 + html += `<span class="key">published:</span> ${escapeHtml(item.published)}\n`; 1114 + } 1115 + if (item.description) { 1116 + html += `<span class="key">description:</span> ${escapeHtml(item.description)}\n`; 1117 + } 1118 + html += '\n'; 1119 + }); 1120 + html += '</pre>'; 1121 + dataContent.innerHTML = html; 1122 + } else { 1123 + // Display as formatted JSON (escaped) 1124 + const jsonStr = JSON.stringify(data, null, 2); 1125 + const escaped = escapeHtml(jsonStr); 1126 + dataContent.innerHTML = `<pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">${escaped}</pre>`; 1127 + } 1128 + } catch (err) { 1129 + dataContent.className = 'output-content output-empty'; 1130 + dataContent.textContent = `Error: ${err.message}`; 1131 + } 1132 + } 1133 + 1134 + async function savePipe() { 1135 + const config = { 1136 + version: "1", 1137 + nodes: nodes, 1138 + connections: connections, 1139 + settings: { enabled: false } 1140 + }; 1141 + 1142 + console.log('Saving config:', config); 1143 + 1144 + const res = await fetch(`/api/pipes/${pipeID}`, { 1145 + method: 'PUT', 1146 + headers: { 'Content-Type': 'application/json' }, 1147 + body: JSON.stringify({ config: config }) 1148 + }); 1149 + 1150 + if (res.ok) { 1151 + showToast('Pipe saved successfully!', 'success'); 1152 + const result = await res.json(); 1153 + console.log('Save result:', result); 1154 + } else { 1155 + const error = await res.text(); 1156 + console.error('Save failed:', error); 1157 + showToast('Failed to save pipe', 'error'); 1158 + } 1159 + } 1160 + 1161 + async function executePipe() { 1162 + const res = await fetch(`/api/pipes/${pipeID}/execute`, { 1163 + method: 'POST' 1164 + }); 1165 + 1166 + if (res.ok) { 1167 + const data = await res.json(); 1168 + showToast('Execution started', 'info'); 1169 + 1170 + // Clear all data sections 1171 + document.querySelectorAll('[id^="data-content-"]').forEach(el => { 1172 + el.className = 'output-content output-empty'; 1173 + el.textContent = 'Running...'; 1174 + }); 1175 + 1176 + // Poll for completion 1177 + pollExecutionStatus(data.executionId); 1178 + } else { 1179 + showToast('Failed to execute pipe', 'error'); 1180 + } 1181 + } 1182 + 1183 + async function pollExecutionStatus(executionId) { 1184 + if (executionStatusInterval) { 1185 + clearInterval(executionStatusInterval); 1186 + } 1187 + 1188 + let attempts = 0; 1189 + const maxAttempts = 30; // 30 seconds max 1190 + 1191 + executionStatusInterval = setInterval(async () => { 1192 + attempts++; 1193 + 1194 + try { 1195 + const execRes = await fetch(`/api/pipes/${pipeID}/executions?limit=1`); 1196 + if (!execRes.ok) return; 1197 + 1198 + const executions = await execRes.json(); 1199 + if (!executions || executions.length === 0) return; 1200 + 1201 + const execution = executions[0]; 1202 + 1203 + if (execution.status === 'success') { 1204 + clearInterval(executionStatusInterval); 1205 + showToast('Execution completed successfully', 'success'); 1206 + 1207 + // Refresh data for all nodes 1208 + nodes.forEach(node => { 1209 + const dataContent = document.getElementById(`data-content-${node.id}`); 1210 + if (dataContent) { 1211 + viewNodeData(node.id); 1212 + } 1213 + }); 1214 + } else if (execution.status === 'failed') { 1215 + clearInterval(executionStatusInterval); 1216 + showToast(`Execution failed: ${execution.error_message || 'Unknown error'}`, 'error'); 1217 + 1218 + // Clear all data sections 1219 + document.querySelectorAll('[id^="data-content-"]').forEach(el => { 1220 + el.className = 'output-content output-empty'; 1221 + el.textContent = 'Execution failed. Check logs.'; 1222 + }); 1223 + } 1224 + 1225 + if (attempts >= maxAttempts) { 1226 + clearInterval(executionStatusInterval); 1227 + showToast('Execution taking longer than expected', 'info'); 1228 + } 1229 + } catch (err) { 1230 + console.error('Polling error:', err); 1231 + } 1232 + }, 1000); // Poll every second 1233 + } 1234 + 1235 + function generateID() { 1236 + return Math.random().toString(36).substring(2, 15); 1237 + } 1238 + 1239 + // Toast notifications 1240 + function showToast(message, type = 'info') { 1241 + const container = document.getElementById('toast-container'); 1242 + const toast = document.createElement('div'); 1243 + toast.className = `toast ${type}`; 1244 + toast.textContent = message; 1245 + 1246 + container.appendChild(toast); 1247 + 1248 + // Auto-dismiss after 3 seconds 1249 + setTimeout(() => { 1250 + toast.classList.add('exit'); 1251 + setTimeout(() => { 1252 + container.removeChild(toast); 1253 + }, 200); // Match animation duration 1254 + }, 3000); 1255 + } 1256 + </script> 1257 + 1258 + <!-- Toast Container --> 1259 + <div id="toast-container"></div> 1260 + </body> 1261 + </html>
+27 -41
web/templates/error.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Error - Pipes</title> 6 + <title>{{.Title}} - Pipes</title> 7 7 <link rel="icon" type="image/svg+xml" href="/public/favicon.svg"> 8 8 <link rel="preconnect" href="https://fonts.googleapis.com"> 9 9 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> ··· 27 27 text-align: center; 28 28 max-width: 600px; 29 29 } 30 + .error-icon { 31 + font-size: 72px; 32 + margin-bottom: 24px; 33 + } 30 34 h1 { 31 - color: #AB4967; 32 - font-size: 48px; 35 + color: #26242b; 36 + font-size: 36px; 33 37 font-weight: 700; 34 38 margin-bottom: 16px; 35 39 text-transform: uppercase; 36 40 letter-spacing: -0.02em; 37 - } 38 - .error-title { 39 - color: #26242b; 40 - font-size: 24px; 41 - font-weight: 700; 42 - margin-bottom: 16px; 43 - text-transform: uppercase; 44 41 } 45 42 .error-message { 46 - color: #4a4a4a; 47 - font-size: 16px; 48 - margin-bottom: 40px; 43 + color: #666; 44 + font-size: 18px; 45 + margin-bottom: 32px; 49 46 font-weight: 500; 50 47 line-height: 1.6; 51 48 } 49 + {{if .Details}} 52 50 .error-details { 53 51 background: #f5f5f0; 54 52 border: 3px solid #26242b; 55 53 padding: 16px; 56 - margin-bottom: 40px; 54 + margin-bottom: 32px; 57 55 text-align: left; 58 56 font-family: monospace; 59 - font-size: 14px; 60 - color: #666; 57 + font-size: 13px; 58 + color: #dc2626; 61 59 word-break: break-word; 62 60 } 61 + {{end}} 63 62 .btn { 64 63 display: inline-block; 65 - padding: 1rem 2rem; 66 - background: #AB4967; 64 + padding: 14px 32px; 65 + background: #2563eb; 67 66 color: #fff; 68 - border: 4px solid #26242b; 69 - font-size: 1rem; 67 + border: 3px solid #26242b; 68 + font-size: 16px; 70 69 font-weight: 700; 71 70 text-decoration: none; 72 71 font-family: 'Space Grotesk', sans-serif; 73 72 text-transform: uppercase; 74 - letter-spacing: 0.1rem; 75 - box-shadow: 6px 6px 0 #26242b; 73 + letter-spacing: 0.05rem; 74 + box-shadow: 4px 4px 0 #26242b; 76 75 transition: all 0.15s ease; 77 76 } 78 77 .btn:hover { 79 - transform: translate(3px, 3px); 80 - box-shadow: 3px 3px 0 #26242b; 78 + transform: translate(2px, 2px); 79 + box-shadow: 2px 2px 0 #26242b; 81 80 } 82 81 .btn:active { 83 - transform: translate(6px, 6px); 82 + transform: translate(4px, 4px); 84 83 box-shadow: 0 0 0 #26242b; 85 84 } 86 - .btn-secondary { 87 - background: #fff; 88 - color: #26242b; 89 - } 90 - .button-group { 91 - display: flex; 92 - flex-direction: column; 93 - gap: 16px; 94 - align-items: center; 95 - } 96 85 </style> 97 86 </head> 98 87 <body> 99 88 <div class="container"> 100 - <h1>Error</h1> 101 - <div class="error-title">{{.Title}}</div> 89 + <div class="error-icon">⚠️</div> 90 + <h1>{{.Title}}</h1> 102 91 <div class="error-message">{{.Message}}</div> 103 92 {{if .Details}} 104 93 <div class="error-details">{{.Details}}</div> 105 94 {{end}} 106 - <div class="button-group"> 107 - <a href="/" class="btn btn-secondary">Go Home</a> 108 - <a href="/auth/login" class="btn">Try Again</a> 109 - </div> 95 + <a href="/dashboard" class="btn">← Back to Dashboard</a> 110 96 </div> 111 97 </body> 112 98 </html>