mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
449 lines
13 KiB
Go
449 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/wavetermdev/waveterm/tsunami/app"
|
|
"github.com/wavetermdev/waveterm/tsunami/vdom"
|
|
)
|
|
|
|
var AppMeta = app.AppMeta{
|
|
Title: "GitHub Actions Monitor",
|
|
ShortDesc: "Real-time GitHub Actions workflow monitoring and status tracking",
|
|
}
|
|
|
|
// Global atoms for config and data
|
|
var (
|
|
pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{
|
|
Desc: "Polling interval for GitHub API requests",
|
|
Units: "s",
|
|
Min: app.Ptr(1.0),
|
|
Max: app.Ptr(300.0),
|
|
})
|
|
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{
|
|
Desc: "GitHub repository in owner/repo format",
|
|
Pattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`,
|
|
})
|
|
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml", &app.AtomMeta{
|
|
Desc: "GitHub Actions workflow file name",
|
|
Pattern: `^.+\.(yml|yaml)$`,
|
|
})
|
|
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{
|
|
Desc: "Maximum number of workflow runs to fetch",
|
|
Min: app.Ptr(1.0),
|
|
Max: app.Ptr(100.0),
|
|
})
|
|
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{
|
|
Desc: "List of GitHub Actions workflow runs",
|
|
})
|
|
lastErrorAtom = app.DataAtom("lastError", "", &app.AtomMeta{
|
|
Desc: "Last error message from GitHub API",
|
|
})
|
|
isLoadingAtom = app.DataAtom("isLoading", true, &app.AtomMeta{
|
|
Desc: "Loading state for workflow data fetch",
|
|
})
|
|
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}, &app.AtomMeta{
|
|
Desc: "Timestamp of last successful data refresh",
|
|
})
|
|
)
|
|
|
|
type WorkflowRun struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
HTMLURL string `json:"html_url"`
|
|
RunNumber int `json:"run_number"`
|
|
}
|
|
|
|
type GitHubResponse struct {
|
|
TotalCount int `json:"total_count"`
|
|
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
|
|
}
|
|
|
|
func fetchWorkflowRuns(repository, workflow string, maxRuns int) ([]WorkflowRun, error) {
|
|
apiKey := os.Getenv("GITHUB_APIKEY")
|
|
if apiKey == "" {
|
|
return nil, fmt.Errorf("GITHUB_APIKEY environment variable not set")
|
|
}
|
|
|
|
url := fmt.Sprintf("https://api.github.com/repos/%s/actions/workflows/%s/runs?per_page=%d", repository, workflow, maxRuns)
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
req.Header.Set("User-Agent", "WaveTerminal-GitHubMonitor")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to make request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var response GitHubResponse
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
return response.WorkflowRuns, nil
|
|
}
|
|
|
|
func getStatusIcon(status, conclusion string) string {
|
|
switch status {
|
|
case "in_progress", "queued", "pending":
|
|
return "🔄"
|
|
case "completed":
|
|
switch conclusion {
|
|
case "success":
|
|
return "✅"
|
|
case "failure":
|
|
return "❌"
|
|
case "cancelled":
|
|
return "🚫"
|
|
case "skipped":
|
|
return "⏭️"
|
|
default:
|
|
return "❓"
|
|
}
|
|
default:
|
|
return "❓"
|
|
}
|
|
}
|
|
|
|
func getStatusColor(status, conclusion string) string {
|
|
switch status {
|
|
case "in_progress", "queued", "pending":
|
|
return "text-yellow-400"
|
|
case "completed":
|
|
switch conclusion {
|
|
case "success":
|
|
return "text-green-400"
|
|
case "failure":
|
|
return "text-red-400"
|
|
case "cancelled":
|
|
return "text-gray-400"
|
|
case "skipped":
|
|
return "text-blue-400"
|
|
default:
|
|
return "text-gray-400"
|
|
}
|
|
default:
|
|
return "text-gray-400"
|
|
}
|
|
}
|
|
|
|
func formatDuration(start, end time.Time, isRunning bool) string {
|
|
if isRunning {
|
|
duration := time.Since(start)
|
|
return fmt.Sprintf("%v (running)", duration.Round(time.Second))
|
|
}
|
|
if end.IsZero() {
|
|
return "Unknown"
|
|
}
|
|
duration := end.Sub(start)
|
|
return duration.Round(time.Second).String()
|
|
}
|
|
|
|
func getDisplayStatus(status, conclusion string) string {
|
|
switch status {
|
|
case "in_progress":
|
|
return "Running"
|
|
case "queued":
|
|
return "Queued"
|
|
case "pending":
|
|
return "Pending"
|
|
case "completed":
|
|
switch conclusion {
|
|
case "success":
|
|
return "Success"
|
|
case "failure":
|
|
return "Failed"
|
|
case "cancelled":
|
|
return "Cancelled"
|
|
case "skipped":
|
|
return "Skipped"
|
|
default:
|
|
return "Completed"
|
|
}
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
type WorkflowRunItemProps struct {
|
|
Run WorkflowRun `json:"run"`
|
|
}
|
|
|
|
var WorkflowRunItem = app.DefineComponent("WorkflowRunItem",
|
|
func(props WorkflowRunItemProps) any {
|
|
run := props.Run
|
|
isRunning := run.Status == "in_progress" || run.Status == "queued" || run.Status == "pending"
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-start justify-between",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex-1 min-w-0",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center gap-3 mb-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-2xl",
|
|
}, getStatusIcon(run.Status, run.Conclusion)),
|
|
vdom.H("a", map[string]any{
|
|
"href": run.HTMLURL,
|
|
"target": "_blank",
|
|
"className": "font-semibold text-blue-400 hover:text-blue-300 cursor-pointer",
|
|
}, run.Name),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-sm text-gray-300",
|
|
}, "#", run.RunNumber),
|
|
),
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center gap-4 text-sm",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": vdom.Classes("font-medium", getStatusColor(run.Status, run.Conclusion)),
|
|
}, getDisplayStatus(run.Status, run.Conclusion)),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-gray-400",
|
|
}, "Duration: ", formatDuration(run.CreatedAt, run.UpdatedAt, isRunning)),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-gray-300",
|
|
}, "Started: ", run.CreatedAt.Format("15:04:05")),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
|
|
var App = app.DefineComponent("App",
|
|
func(_ struct{}) any {
|
|
|
|
fetchData := func() {
|
|
currentMaxRuns := maxWorkflowRunsAtom.Get()
|
|
runs, err := fetchWorkflowRuns(repositoryAtom.Get(), workflowAtom.Get(), currentMaxRuns)
|
|
if err != nil {
|
|
log.Printf("Error fetching workflow runs: %v", err)
|
|
lastErrorAtom.Set(err.Error())
|
|
} else {
|
|
sort.Slice(runs, func(i, j int) bool {
|
|
return runs[i].CreatedAt.After(runs[j].CreatedAt)
|
|
})
|
|
workflowRunsAtom.Set(runs)
|
|
lastErrorAtom.Set("")
|
|
}
|
|
lastRefreshTimeAtom.Set(time.Now())
|
|
isLoadingAtom.Set(false)
|
|
}
|
|
|
|
// Initial fetch on mount
|
|
app.UseEffect(func() func() {
|
|
fetchData()
|
|
return nil
|
|
}, []any{})
|
|
|
|
// Automatic polling with UseTicker - automatically cleaned up on unmount
|
|
app.UseTicker(time.Duration(pollIntervalAtom.Get())*time.Second, func() {
|
|
fetchData()
|
|
}, []any{pollIntervalAtom.Get()})
|
|
|
|
handleRefresh := func() {
|
|
isLoadingAtom.Set(true)
|
|
go func() {
|
|
fetchData()
|
|
}()
|
|
}
|
|
|
|
workflowRuns := workflowRunsAtom.Get()
|
|
lastError := lastErrorAtom.Get()
|
|
isLoading := isLoadingAtom.Get()
|
|
lastRefreshTime := lastRefreshTimeAtom.Get()
|
|
pollInterval := pollIntervalAtom.Get()
|
|
repository := repositoryAtom.Get()
|
|
workflow := workflowAtom.Get()
|
|
maxWorkflowRuns := maxWorkflowRunsAtom.Get()
|
|
|
|
return vdom.H("div", map[string]any{
|
|
"className": "min-h-screen bg-gray-900 text-white p-6",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "max-w-6xl mx-auto",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "mb-8",
|
|
},
|
|
vdom.H("h1", map[string]any{
|
|
"className": "text-3xl font-bold text-white mb-2",
|
|
}, "GitHub Actions Monitor"),
|
|
vdom.H("p", map[string]any{
|
|
"className": "text-gray-400",
|
|
}, "Monitoring ", repositoryAtom.Get(), " ", workflowAtom.Get(), " workflow"),
|
|
),
|
|
|
|
vdom.H("div", map[string]any{
|
|
"className": "bg-gray-800 rounded-lg p-4 mb-6",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center justify-between",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center gap-4",
|
|
},
|
|
vdom.H("button", map[string]any{
|
|
"className": vdom.Classes(
|
|
"px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer",
|
|
vdom.IfElse(isLoadingAtom.Get(), "bg-gray-600 text-gray-400", "bg-blue-600 hover:bg-blue-700 text-white"),
|
|
),
|
|
"onClick": vdom.If(!isLoadingAtom.Get(), handleRefresh),
|
|
"disabled": isLoadingAtom.Get(),
|
|
}, vdom.IfElse(isLoadingAtom.Get(), "Refreshing...", "Refresh")),
|
|
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center gap-2",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": vdom.Classes("w-2 h-2 rounded-full", vdom.IfElse(lastError == "", "bg-green-500", "bg-red-500")),
|
|
}),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-sm text-gray-400",
|
|
}, vdom.IfElse(lastError == "", "Connected", "Error")),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-sm text-gray-300 ml-2",
|
|
}, "Poll interval: ", pollInterval, "s"),
|
|
vdom.If(!lastRefreshTime.IsZero(),
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-sm text-gray-300 ml-4",
|
|
}, "Last refresh: ", lastRefreshTime.Format("15:04:05")),
|
|
),
|
|
),
|
|
),
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-sm text-gray-300",
|
|
}, "Last ", maxWorkflowRuns, " workflow runs"),
|
|
),
|
|
),
|
|
|
|
vdom.If(lastError != "",
|
|
vdom.H("div", map[string]any{
|
|
"className": "bg-red-900 bg-opacity-50 border border-red-700 rounded-lg p-4 mb-6",
|
|
},
|
|
vdom.H("div", map[string]any{
|
|
"className": "flex items-center gap-2 text-red-200",
|
|
},
|
|
vdom.H("span", nil, "❌"),
|
|
vdom.H("strong", nil, "Error:"),
|
|
),
|
|
vdom.H("p", map[string]any{
|
|
"className": "text-red-100 mt-1",
|
|
}, lastError),
|
|
),
|
|
),
|
|
|
|
vdom.H("div", map[string]any{
|
|
"className": "space-y-4",
|
|
},
|
|
vdom.If(isLoading && len(workflowRuns) == 0,
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-center py-8 text-gray-400",
|
|
}, "Loading workflow runs..."),
|
|
),
|
|
vdom.If(len(workflowRuns) > 0,
|
|
vdom.ForEach(workflowRuns, func(run WorkflowRun, idx int) any {
|
|
return WorkflowRunItem(WorkflowRunItemProps{
|
|
Run: run,
|
|
}).WithKey(strconv.FormatInt(run.ID, 10))
|
|
}),
|
|
),
|
|
vdom.If(!isLoading && len(workflowRuns) == 0 && lastError == "",
|
|
vdom.H("div", map[string]any{
|
|
"className": "text-center py-8 text-gray-400",
|
|
}, "No workflow runs found"),
|
|
),
|
|
),
|
|
|
|
vdom.H("div", map[string]any{
|
|
"className": "mt-8 bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4",
|
|
},
|
|
vdom.H("h3", map[string]any{
|
|
"className": "text-lg font-semibold text-blue-200 mb-2",
|
|
}, "GitHub Actions Monitor Features"),
|
|
vdom.H("ul", map[string]any{
|
|
"className": "space-y-2 text-blue-100",
|
|
},
|
|
vdom.H("li", map[string]any{
|
|
"className": "flex items-start gap-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-blue-400 mt-1",
|
|
}, "•"),
|
|
"Monitors ", repository, " ", workflow, " workflow",
|
|
),
|
|
vdom.H("li", map[string]any{
|
|
"className": "flex items-start gap-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-blue-400 mt-1",
|
|
}, "•"),
|
|
"Polls GitHub API every ", pollInterval, " seconds for real-time updates",
|
|
),
|
|
vdom.H("li", map[string]any{
|
|
"className": "flex items-start gap-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-blue-400 mt-1",
|
|
}, "•"),
|
|
"Shows status icons: ✅ Success, ❌ Failure, 🔄 Running",
|
|
),
|
|
vdom.H("li", map[string]any{
|
|
"className": "flex items-start gap-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-blue-400 mt-1",
|
|
}, "•"),
|
|
"Clickable workflow names open in GitHub (new tab)",
|
|
),
|
|
vdom.H("li", map[string]any{
|
|
"className": "flex items-start gap-2",
|
|
},
|
|
vdom.H("span", map[string]any{
|
|
"className": "text-blue-400 mt-1",
|
|
}, "•"),
|
|
"Live duration tracking for running jobs",
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|