diff --git a/.gitignore b/.gitignore
index 97c815b..54aa23f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,5 @@ go.work.sum
.run/
.DS_Store
+
+gateway-config-dev*.yaml
diff --git a/config/gateway-config-dev.yaml b/config/gateway-config-dev.yaml
index 1c3ee23..598943b 100644
--- a/config/gateway-config-dev.yaml
+++ b/config/gateway-config-dev.yaml
@@ -32,7 +32,7 @@ sparkManagerPort: "8081"
gateway:
gatewayApiVersion: v1
- gatewayPort: "8080"
+ gatewayPort: "8082"
middleware:
- type: RegexBasicAuthAllowMiddleware
diff --git a/go.mod b/go.mod
index adc0a05..02e64c7 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,8 @@ require (
)
require (
+ github.com/a-h/templ v0.3.943
+ github.com/a-h/templ/examples/integration-gin v0.0.0-20250818063052-abb427c0fb8d
github.com/jackc/pgx/v5 v5.7.4
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/prometheus/client_golang v1.22.0
diff --git a/go.sum b/go.sum
index 5235bcd..591ec9f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
+github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
+github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
+github.com/a-h/templ/examples/integration-gin v0.0.0-20250818063052-abb427c0fb8d h1:L8EvgXLCOLvgMjyWloBGrxWJGjsAAHyR5df79sC6IOc=
+github.com/a-h/templ/examples/integration-gin v0.0.0-20250818063052-abb427c0fb8d/go.mod h1:1t59aGzdEXDnMYwmfWTKqDSASvUPtBe4iH00XPvh54U=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
@@ -70,8 +74,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
+github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -133,8 +137,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
-github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
+github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
+github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
diff --git a/internal/gateway/application/handler/handler.go b/internal/gateway/application/handler/handler.go
index b74703c..e630136 100644
--- a/internal/gateway/application/handler/handler.go
+++ b/internal/gateway/application/handler/handler.go
@@ -19,14 +19,18 @@ import (
"context"
"errors"
"fmt"
- swaggerDocs "github.com/slackhq/spark-gateway/docs/swagger"
- swaggerFiles "github.com/swaggo/files"
- ginSwagger "github.com/swaggo/gin-swagger"
"net/http"
"strconv"
+ "strings"
+
+ swaggerFiles "github.com/swaggo/files"
+ ginSwagger "github.com/swaggo/gin-swagger"
+
+ swaggerDocs "github.com/slackhq/spark-gateway/docs/swagger"
"github.com/gin-gonic/gin"
"github.com/kubeflow/spark-operator/v2/api/v1beta2"
+
"github.com/slackhq/spark-gateway/pkg/gatewayerrors"
pkgHttp "github.com/slackhq/spark-gateway/pkg/http"
"github.com/slackhq/spark-gateway/pkg/model"
@@ -47,7 +51,7 @@ const sparkApplicationPathName = "applications"
type GatewayApplicationService interface {
Get(ctx context.Context, gatewayId string) (*model.GatewayApplication, error)
- List(ctx context.Context, cluster string, namespace string) ([]*model.GatewayApplicationMeta, error)
+ List(ctx context.Context, cluster string, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.GatewayApplicationMeta, error)
Create(ctx context.Context, application *v1beta2.SparkApplication, user string) (*model.GatewayApplication, error)
Status(ctx context.Context, gatewayId string) (*v1beta2.SparkApplicationStatus, error)
Logs(ctx context.Context, gatewayId string, tailLines int) (*string, error)
@@ -90,6 +94,7 @@ func (h *ApplicationHandler) RegisterRoutes(rg *gin.RouterGroup) {
// @Security BasicAuth
// @Param cluster query string true "Cluster name"
// @Param namespace query string false "Namespace (optional)"
+// @Param appState query v1beta2.ApplicationStateType false "Filter by Spark Application state"
// @Success 200 {array} model.GatewayApplicationMeta "List of SparkApplication metadata"
// @Router / [get]
func (h *ApplicationHandler) List(c *gin.Context) {
@@ -102,7 +107,18 @@ func (h *ApplicationHandler) List(c *gin.Context) {
namespace := c.Query("namespace")
- appMetaList, err := h.service.List(c, cluster, namespace)
+ _appState := strings.ToUpper(c.Query("appState"))
+ var appState *v1beta2.ApplicationStateType = nil
+ if _appState != "" {
+ state := v1beta2.ApplicationStateType(_appState)
+ if !model.ValidSparkApplicationStatesMap[state] {
+ c.Error(fmt.Errorf("invalid application state: %s", state))
+ return
+ }
+ appState = &state
+ }
+
+ appMetaList, err := h.service.List(c, cluster, namespace, appState)
if err != nil {
c.Error(err)
diff --git a/internal/gateway/application/handler/mockgatewayapplicationservice.go b/internal/gateway/application/handler/mockgatewayapplicationservice.go
index 935f991..ba67c32 100644
--- a/internal/gateway/application/handler/mockgatewayapplicationservice.go
+++ b/internal/gateway/application/handler/mockgatewayapplicationservice.go
@@ -29,7 +29,7 @@ var _ GatewayApplicationService = &GatewayApplicationServiceMock{}
// GetFunc: func(ctx context.Context, gatewayId string) (*model.GatewayApplication, error) {
// panic("mock out the Get method")
// },
-// ListFunc: func(ctx context.Context, cluster string, namespace string) ([]*model.GatewayApplicationMeta, error) {
+// ListFunc: func(ctx context.Context, cluster string, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.GatewayApplicationMeta, error) {
// panic("mock out the List method")
// },
// LogsFunc: func(ctx context.Context, gatewayId string, tailLines int) (*string, error) {
@@ -55,7 +55,7 @@ type GatewayApplicationServiceMock struct {
GetFunc func(ctx context.Context, gatewayId string) (*model.GatewayApplication, error)
// ListFunc mocks the List method.
- ListFunc func(ctx context.Context, cluster string, namespace string) ([]*model.GatewayApplicationMeta, error)
+ ListFunc func(ctx context.Context, cluster string, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.GatewayApplicationMeta, error)
// LogsFunc mocks the Logs method.
LogsFunc func(ctx context.Context, gatewayId string, tailLines int) (*string, error)
@@ -96,6 +96,8 @@ type GatewayApplicationServiceMock struct {
Cluster string
// Namespace is the namespace argument value.
Namespace string
+ // AppState is the appState argument value.
+ AppState *v1beta2.ApplicationStateType
}
// Logs holds details about calls to the Logs method.
Logs []struct {
@@ -235,7 +237,7 @@ func (mock *GatewayApplicationServiceMock) GetCalls() []struct {
}
// List calls ListFunc.
-func (mock *GatewayApplicationServiceMock) List(ctx context.Context, cluster string, namespace string) ([]*model.GatewayApplicationMeta, error) {
+func (mock *GatewayApplicationServiceMock) List(ctx context.Context, cluster string, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.GatewayApplicationMeta, error) {
if mock.ListFunc == nil {
panic("GatewayApplicationServiceMock.ListFunc: method is nil but GatewayApplicationService.List was just called")
}
@@ -243,15 +245,17 @@ func (mock *GatewayApplicationServiceMock) List(ctx context.Context, cluster str
Ctx context.Context
Cluster string
Namespace string
+ AppState *v1beta2.ApplicationStateType
}{
Ctx: ctx,
Cluster: cluster,
Namespace: namespace,
+ AppState: appState,
}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
- return mock.ListFunc(ctx, cluster, namespace)
+ return mock.ListFunc(ctx, cluster, namespace, appState)
}
// ListCalls gets all the calls that were made to List.
@@ -262,11 +266,13 @@ func (mock *GatewayApplicationServiceMock) ListCalls() []struct {
Ctx context.Context
Cluster string
Namespace string
+ AppState *v1beta2.ApplicationStateType
} {
var calls []struct {
Ctx context.Context
Cluster string
Namespace string
+ AppState *v1beta2.ApplicationStateType
}
mock.lockList.RLock()
calls = mock.calls.List
diff --git a/internal/gateway/application/repository/repository.go b/internal/gateway/application/repository/repository.go
index a1dca3b..81f0d36 100644
--- a/internal/gateway/application/repository/repository.go
+++ b/internal/gateway/application/repository/repository.go
@@ -99,11 +99,16 @@ func (r *SparkManagerRepository) Get(ctx context.Context, cluster model.KubeClus
return kube.Sanitize(&app), nil
}
-func (r *SparkManagerRepository) List(ctx context.Context, cluster model.KubeCluster, namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (r *SparkManagerRepository) List(ctx context.Context, cluster model.KubeCluster, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
clusterEndpoint := r.ClusterEndpoints[cluster.Name]
- // Url: http://host:port/namespace
- url := fmt.Sprintf("%s/%s", clusterEndpoint, namespace)
+ // Url: http://host:port/namespace?appState=appState
+ var url string
+ if appState == nil {
+ url = fmt.Sprintf("%s/%s", clusterEndpoint, namespace)
+ } else {
+ url = fmt.Sprintf("%s/%s?appState=%s", clusterEndpoint, namespace, string(*appState))
+ }
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
diff --git a/internal/gateway/application/service/mocksparkapplicationrepository.go b/internal/gateway/application/service/mocksparkapplicationrepository.go
index 896e497..0c45def 100644
--- a/internal/gateway/application/service/mocksparkapplicationrepository.go
+++ b/internal/gateway/application/service/mocksparkapplicationrepository.go
@@ -29,7 +29,7 @@ var _ SparkApplicationRepository = &SparkApplicationRepositoryMock{}
// GetFunc: func(ctx context.Context, cluster model.KubeCluster, namespace string, name string) (*v1beta2.SparkApplication, error) {
// panic("mock out the Get method")
// },
-// ListFunc: func(ctx context.Context, cluster model.KubeCluster, namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+// ListFunc: func(ctx context.Context, cluster model.KubeCluster, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
// panic("mock out the List method")
// },
// LogsFunc: func(ctx context.Context, cluster model.KubeCluster, namespace string, name string, tailLines int) (*string, error) {
@@ -55,7 +55,7 @@ type SparkApplicationRepositoryMock struct {
GetFunc func(ctx context.Context, cluster model.KubeCluster, namespace string, name string) (*v1beta2.SparkApplication, error)
// ListFunc mocks the List method.
- ListFunc func(ctx context.Context, cluster model.KubeCluster, namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ ListFunc func(ctx context.Context, cluster model.KubeCluster, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
// LogsFunc mocks the Logs method.
LogsFunc func(ctx context.Context, cluster model.KubeCluster, namespace string, name string, tailLines int) (*string, error)
@@ -104,6 +104,8 @@ type SparkApplicationRepositoryMock struct {
Cluster model.KubeCluster
// Namespace is the namespace argument value.
Namespace string
+ // AppState is the appState argument value.
+ AppState *v1beta2.ApplicationStateType
}
// Logs holds details about calls to the Logs method.
Logs []struct {
@@ -267,7 +269,7 @@ func (mock *SparkApplicationRepositoryMock) GetCalls() []struct {
}
// List calls ListFunc.
-func (mock *SparkApplicationRepositoryMock) List(ctx context.Context, cluster model.KubeCluster, namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (mock *SparkApplicationRepositoryMock) List(ctx context.Context, cluster model.KubeCluster, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
if mock.ListFunc == nil {
panic("SparkApplicationRepositoryMock.ListFunc: method is nil but SparkApplicationRepository.List was just called")
}
@@ -275,15 +277,17 @@ func (mock *SparkApplicationRepositoryMock) List(ctx context.Context, cluster mo
Ctx context.Context
Cluster model.KubeCluster
Namespace string
+ AppState *v1beta2.ApplicationStateType
}{
Ctx: ctx,
Cluster: cluster,
Namespace: namespace,
+ AppState: appState,
}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
- return mock.ListFunc(ctx, cluster, namespace)
+ return mock.ListFunc(ctx, cluster, namespace, appState)
}
// ListCalls gets all the calls that were made to List.
@@ -294,11 +298,13 @@ func (mock *SparkApplicationRepositoryMock) ListCalls() []struct {
Ctx context.Context
Cluster model.KubeCluster
Namespace string
+ AppState *v1beta2.ApplicationStateType
} {
var calls []struct {
Ctx context.Context
Cluster model.KubeCluster
Namespace string
+ AppState *v1beta2.ApplicationStateType
}
mock.lockList.RLock()
calls = mock.calls.List
diff --git a/internal/gateway/application/service/service.go b/internal/gateway/application/service/service.go
index 270c351..97d6e9d 100644
--- a/internal/gateway/application/service/service.go
+++ b/internal/gateway/application/service/service.go
@@ -17,7 +17,6 @@ package service
import (
"context"
- "errors"
"fmt"
"strings"
@@ -39,7 +38,7 @@ import (
type SparkApplicationRepository interface {
Get(ctx context.Context, cluster model.KubeCluster, namespace string, name string) (*v1beta2.SparkApplication, error)
- List(ctx context.Context, cluster model.KubeCluster, namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ List(ctx context.Context, cluster model.KubeCluster, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
Status(ctx context.Context, cluster model.KubeCluster, namespace string, name string) (*v1beta2.SparkApplicationStatus, error)
Logs(ctx context.Context, cluster model.KubeCluster, namespace string, name string, tailLines int) (*string, error)
Create(ctx context.Context, cluster model.KubeCluster, application *v1beta2.SparkApplication) (*v1beta2.SparkApplication, error)
@@ -109,24 +108,24 @@ func (s *service) Get(ctx context.Context, gatewayId string) (*model.GatewayAppl
return nil, gatewayerrors.NewFrom(fmt.Errorf("error getting SparkApplication '%s': %w", gatewayId, err))
}
- user, ok := sparkApp.Labels[model.GATEWAY_USER_LABEL]
- if !ok {
- return nil, gatewayerrors.NewFrom(errors.New("no gateway user associated with this application, possibly not created through spark-gateway?"))
+ user, err := model.GetUser(sparkApp.Labels)
+ if err != nil {
+ return nil, gatewayerrors.NewFrom(err)
}
gatewayApp := &model.GatewayApplication{
SparkApplication: sparkApp,
GatewayId: sparkApp.Name,
Cluster: cluster.Name,
- User: user,
+ User: *user,
SparkLogURLs: GetRenderedURLs(s.config.StatusUrlTemplates, sparkApp),
}
return gatewayApp, nil
}
-// List retrieves `num` number of GatewayApplications from specified namespace `namespace` in cluster `cluster`
-func (s *service) List(ctx context.Context, cluster string, namespace string) ([]*model.GatewayApplicationMeta, error) {
+// List retrieves a list of GatewayApplicationMeta from specified namespace `namespace` in cluster `cluster` with appState state
+func (s *service) List(ctx context.Context, cluster string, namespace string, appState *v1beta2.ApplicationStateType) ([]*model.GatewayApplicationMeta, error) {
kubeCluster, err := s.clusterRepository.GetByName(cluster)
@@ -149,13 +148,17 @@ func (s *service) List(ctx context.Context, cluster string, namespace string) ([
var appMetaList []*model.GatewayApplicationMeta
for _, ns := range namespaces {
- nsAppMetas, err := s.sparkAppRepo.List(ctx, *kubeCluster, ns)
+ nsAppMetas, err := s.sparkAppRepo.List(ctx, *kubeCluster, ns, appState)
if err != nil {
return nil, gatewayerrors.NewFrom(fmt.Errorf("error getting applications: %w", err))
}
for _, appMeta := range nsAppMetas {
- appMetaList = append(appMetaList, model.NewGatewayApplicationMeta(appMeta, cluster))
+ user, err := model.GetUser(appMeta.ObjectMeta.Labels)
+ if user == nil || err != nil {
+ continue
+ }
+ appMetaList = append(appMetaList, model.NewGatewayApplicationMeta(appMeta, cluster, *user))
}
}
diff --git a/internal/gateway/cluster/local_repo.go b/internal/gateway/cluster/local_repo.go
index a21d5b1..5d91c43 100644
--- a/internal/gateway/cluster/local_repo.go
+++ b/internal/gateway/cluster/local_repo.go
@@ -80,7 +80,7 @@ func (r *LocalClusterRepo) GetAll() ([]model.KubeCluster, error) {
}
if len(clusters) == 0 {
- klog.Warningf("GetAll: no clusters found in clusters config")
+ return nil, fmt.Errorf("GetAll: no clusters found in clusters config")
}
return clusters, nil
diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go
index eb3ccd1..e3d528a 100644
--- a/internal/gateway/server/server.go
+++ b/internal/gateway/server/server.go
@@ -28,6 +28,7 @@ import (
"github.com/slackhq/spark-gateway/internal/gateway/application/repository"
"github.com/slackhq/spark-gateway/internal/gateway/application/service"
"github.com/slackhq/spark-gateway/internal/gateway/cluster"
+ "github.com/slackhq/spark-gateway/internal/gateway/web"
"time"
@@ -128,6 +129,10 @@ func NewGateway(ctx context.Context, sgConfig *cfg.SparkGatewayConfig, sparkMana
handler.RegisterSwaggerDocs(rootGroup, sgConfig.GatewayConfig.GatewayApiVersion)
}
+ // Register UI
+ webHandler := web.NewWebHandler(localClusterRepo, appService, ginRouter, rootGroup)
+ webHandler.RegisterRoutes()
+
/// Register versioned handlers
versionGroup := ginRouter.Group(fmt.Sprintf("/%s", sgConfig.GatewayConfig.GatewayApiVersion), mwHandlerChain...)
appHandler.RegisterRoutes(versionGroup)
diff --git a/internal/gateway/web/app/clusters.templ b/internal/gateway/web/app/clusters.templ
new file mode 100644
index 0000000..f7fa64f
--- /dev/null
+++ b/internal/gateway/web/app/clusters.templ
@@ -0,0 +1,81 @@
+package app
+
+import (
+ "fmt"
+ "github.com/slackhq/spark-gateway/pkg/model"
+)
+
+templ Clusters(clusters []model.KubeCluster) {
+ @Layout("Clusters - Spark Gateway") {
+ @ClustersContent(clusters)
+ }
+}
+
+templ ClustersContent(clusters []model.KubeCluster) {
+
+
+
Clusters
+
Manage your Kubernetes clusters
+
+
+
+ if len(clusters) == 0 {
+
+
No clusters configured
+
+ } else {
+
+
+
+ | Name |
+ Cluster ID |
+ Master URL |
+ Routing Weight |
+ Namespaces |
+
+
+
+ for i, cluster := range clusters {
+
+ |
+ { cluster.Name }
+ |
+
+ { cluster.ClusterId }
+ |
+
+ { cluster.MasterURL }
+ |
+
+
+ { formatWeight(cluster.RoutingWeight) }
+
+ |
+
+
+ for _, namespace := range cluster.Namespaces {
+
+ { namespace.Name }
+
+ }
+
+ |
+
+ }
+
+
+ }
+
+
+}
+
+func getRowColor(index int) string {
+ if index%2 == 0 {
+ return "#ffffff"
+ }
+ return "#f8f9fa"
+}
+
+func formatWeight(weight float64) string {
+ return fmt.Sprintf("%.1f", weight)
+}
\ No newline at end of file
diff --git a/internal/gateway/web/app/clusters_templ.go b/internal/gateway/web/app/clusters_templ.go
new file mode 100644
index 0000000..708b55a
--- /dev/null
+++ b/internal/gateway/web/app/clusters_templ.go
@@ -0,0 +1,229 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.943
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/slackhq/spark-gateway/pkg/model"
+)
+
+func Clusters(clusters []model.KubeCluster) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = ClustersContent(clusters).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Clusters - Spark Gateway").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func ClustersContent(clusters []model.KubeCluster) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Clusters
Manage your Kubernetes clusters
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(clusters) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
| Name | Cluster ID | Master URL | Routing Weight | Namespaces |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for i, cluster := range clusters {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(cluster.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/clusters.templ`, Line: 41, Col: 67}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(cluster.ClusterId)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/clusters.templ`, Line: 44, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(cluster.MasterURL)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/clusters.templ`, Line: 47, Col: 181}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatWeight(cluster.RoutingWeight))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/clusters.templ`, Line: 51, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, namespace := range cluster.Namespaces {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(namespace.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/clusters.templ`, Line: 58, Col: 28}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func getRowColor(index int) string {
+ if index%2 == 0 {
+ return "#ffffff"
+ }
+ return "#f8f9fa"
+}
+
+func formatWeight(weight float64) string {
+ return fmt.Sprintf("%.1f", weight)
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/gateway/web/app/layout.templ b/internal/gateway/web/app/layout.templ
new file mode 100644
index 0000000..5487744
--- /dev/null
+++ b/internal/gateway/web/app/layout.templ
@@ -0,0 +1,71 @@
+package app
+
+import "github.com/slackhq/spark-gateway/internal/gateway/web/components"
+
+templ Layout(title string) {
+
+
+
+
+
+ { title }
+
+
+
+
+
+ @components.Header()
+ @components.Sidebar()
+
+ { children... }
+
+
+
+
+}
\ No newline at end of file
diff --git a/internal/gateway/web/app/layout_templ.go b/internal/gateway/web/app/layout_templ.go
new file mode 100644
index 0000000..648d15e
--- /dev/null
+++ b/internal/gateway/web/app/layout_templ.go
@@ -0,0 +1,75 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.943
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "github.com/slackhq/spark-gateway/internal/gateway/web/components"
+
+func Layout(title string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/layout.templ`, Line: 11, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = components.Header().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = components.Sidebar().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/gateway/web/app/main.templ b/internal/gateway/web/app/main.templ
new file mode 100644
index 0000000..b894847
--- /dev/null
+++ b/internal/gateway/web/app/main.templ
@@ -0,0 +1,326 @@
+package app
+
+import (
+ "fmt"
+ "time"
+ "github.com/slackhq/spark-gateway/pkg/model"
+ "github.com/kubeflow/spark-operator/v2/api/v1beta2"
+)
+
+type ApplicationCounts struct {
+ Submitted int
+ Running int
+ Completed int
+ Failed int
+}
+
+templ Main(counts ApplicationCounts, clusters []model.KubeCluster, applications []*model.GatewayApplicationMeta, selectedCluster, selectedNamespace string, namespaces []model.KubeNamespace) {
+ @Layout("Spark Gateway") {
+ @MainContent(counts, clusters, applications, selectedCluster, selectedNamespace, namespaces)
+ }
+}
+
+templ MainContent(counts ApplicationCounts, clusters []model.KubeCluster, applications []*model.GatewayApplicationMeta, selectedCluster, selectedNamespace string, namespaces []model.KubeNamespace) {
+
+
+
Welcome to Spark Gateway
+
Monitor your Spark applications
+
+
+
+
Application Status
+
+
+
{ fmt.Sprintf("%d", counts.Submitted) }
+
Submitted
+
+
+
{ fmt.Sprintf("%d", counts.Running) }
+
Running
+
+
+
{ fmt.Sprintf("%d", counts.Completed) }
+
Completed
+
+
+
{ fmt.Sprintf("%d", counts.Failed) }
+
Failed
+
+
+
+
+
+
+
+ if selectedCluster == "" {
+
+
Please select a cluster to view applications
+
+ } else if selectedNamespace == "" {
+
+
Please select a namespace to view applications
+
+ } else if len(applications) == 0 {
+
+
No applications found in { selectedCluster }/{ selectedNamespace }
+
+ } else {
+
+
+
+
+ | Name |
+ Spark App ID |
+ Submission Time |
+ Termination Time |
+ User |
+ Driver Info |
+ App State |
+ Submission Attempts |
+ Spec |
+
+
+
+ for i, app := range applications {
+
+ |
+ { app.SparkAppMeta.ObjectMeta.Name }
+ |
+
+ { app.SparkAppMeta.SparkApplicationID }
+ |
+
+ { formatTime(app.SparkAppMeta.LastSubmissionAttemptTime.Time) }
+ |
+
+ { formatTime(app.SparkAppMeta.TerminationTime.Time) }
+ |
+
+ { app.User }
+ |
+
+ if app.SparkAppMeta.DriverInfo.PodName != "" || app.SparkAppMeta.DriverInfo.WebUIIngressAddress != "" {
+
+ ▶
+ Driver Info
+
+
+ if app.SparkAppMeta.DriverInfo.PodName != "" {
+
+ Pod Name: { app.SparkAppMeta.DriverInfo.PodName }
+
+ }
+ if app.SparkAppMeta.DriverInfo.WebUIIngressAddress != "" {
+
+ Ingress Address: { app.SparkAppMeta.DriverInfo.WebUIIngressAddress }
+
+ }
+
+ } else {
+ No driver info
+ }
+ |
+
+
+ { formatAppState(app.SparkAppMeta.AppState) }
+
+ |
+
+ { fmt.Sprintf("%d", app.SparkAppMeta.SubmissionAttempts) }
+ |
+
+
+ |
+
+ }
+
+
+
+ }
+
+
+
+}
+
+func formatTime(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Format("2006-01-02 15:04:05")
+}
+
+func formatAppState(state v1beta2.ApplicationState) string {
+ return string(state.State)
+}
+
+func getStateColor(state string) string {
+ switch state {
+ case "COMPLETED":
+ return "#28a745"
+ case "RUNNING":
+ return "#007bff"
+ case "FAILED":
+ return "#dc3545"
+ case "PENDING":
+ return "#ffc107"
+ case "SUBMISSION_FAILED":
+ return "#dc3545"
+ case "INVALIDATING":
+ return "#6c757d"
+ case "SUCCEEDING":
+ return "#17a2b8"
+ case "FAILING":
+ return "#fd7e14"
+ default:
+ return "#6c757d"
+ }
+}
diff --git a/internal/gateway/web/app/main_templ.go b/internal/gateway/web/app/main_templ.go
new file mode 100644
index 0000000..a921090
--- /dev/null
+++ b/internal/gateway/web/app/main_templ.go
@@ -0,0 +1,576 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.943
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/kubeflow/spark-operator/v2/api/v1beta2"
+ "github.com/slackhq/spark-gateway/pkg/model"
+ "time"
+)
+
+type ApplicationCounts struct {
+ Submitted int
+ Running int
+ Completed int
+ Failed int
+}
+
+func Main(counts ApplicationCounts, clusters []model.KubeCluster, applications []*model.GatewayApplicationMeta, selectedCluster, selectedNamespace string, namespaces []model.KubeNamespace) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = MainContent(counts, clusters, applications, selectedCluster, selectedNamespace, namespaces).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Spark Gateway").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func MainContent(counts ApplicationCounts, clusters []model.KubeCluster, applications []*model.GatewayApplicationMeta, selectedCluster, selectedNamespace string, namespaces []model.KubeNamespace) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Welcome to Spark Gateway
Monitor your Spark applications
Application Status
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", counts.Submitted))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 34, Col: 115}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Submitted
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", counts.Running))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 38, Col: 113}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Running
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", counts.Completed))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 42, Col: 115}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
Completed
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", counts.Failed))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 46, Col: 112}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Failed
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if selectedCluster == "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Please select a cluster to view applications
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if selectedNamespace == "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Please select a namespace to view applications
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if len(applications) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
No applications found in ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(selectedCluster)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 117, Col: 88}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "/")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(selectedNamespace)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 117, Col: 110}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
| Name | Spark App ID | Submission Time | Termination Time | User | Driver Info | App State | Submission Attempts | Spec |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for i, app := range applications {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(app.SparkAppMeta.ObjectMeta.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 139, Col: 88}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(app.SparkAppMeta.SparkApplicationID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 142, Col: 137}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(app.SparkAppMeta.LastSubmissionAttemptTime.Time))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 145, Col: 114}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(app.SparkAppMeta.TerminationTime.Time))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 148, Col: 104}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(app.User)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 151, Col: 93}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if app.SparkAppMeta.DriverInfo.PodName != "" || app.SparkAppMeta.DriverInfo.WebUIIngressAddress != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ▶ Driver Info ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if app.SparkAppMeta.DriverInfo.PodName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " Pod Name: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(app.SparkAppMeta.DriverInfo.PodName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 169, Col: 78}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if app.SparkAppMeta.DriverInfo.WebUIIngressAddress != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " Ingress Address: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(app.SparkAppMeta.DriverInfo.WebUIIngressAddress)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 174, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "No driver info")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(formatAppState(app.SparkAppMeta.AppState))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 184, Col: 54}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", app.SparkAppMeta.SubmissionAttempts))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/gateway/web/app/main.templ`, Line: 188, Col: 111}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " | |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func formatTime(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Format("2006-01-02 15:04:05")
+}
+
+func formatAppState(state v1beta2.ApplicationState) string {
+ return string(state.State)
+}
+
+func getStateColor(state string) string {
+ switch state {
+ case "COMPLETED":
+ return "#28a745"
+ case "RUNNING":
+ return "#007bff"
+ case "FAILED":
+ return "#dc3545"
+ case "PENDING":
+ return "#ffc107"
+ case "SUBMISSION_FAILED":
+ return "#dc3545"
+ case "INVALIDATING":
+ return "#6c757d"
+ case "SUCCEEDING":
+ return "#17a2b8"
+ case "FAILING":
+ return "#fd7e14"
+ default:
+ return "#6c757d"
+ }
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/gateway/web/components/header.templ b/internal/gateway/web/components/header.templ
new file mode 100644
index 0000000..b4eb3a9
--- /dev/null
+++ b/internal/gateway/web/components/header.templ
@@ -0,0 +1,11 @@
+package components
+
+templ Header() {
+
+}
\ No newline at end of file
diff --git a/internal/gateway/web/components/header_templ.go b/internal/gateway/web/components/header_templ.go
new file mode 100644
index 0000000..9772f36
--- /dev/null
+++ b/internal/gateway/web/components/header_templ.go
@@ -0,0 +1,40 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.943
+package components
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Header() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/gateway/web/components/sidebar.templ b/internal/gateway/web/components/sidebar.templ
new file mode 100644
index 0000000..0bc6e76
--- /dev/null
+++ b/internal/gateway/web/components/sidebar.templ
@@ -0,0 +1,36 @@
+package components
+
+templ Sidebar() {
+
+}
\ No newline at end of file
diff --git a/internal/gateway/web/components/sidebar_templ.go b/internal/gateway/web/components/sidebar_templ.go
new file mode 100644
index 0000000..42bed43
--- /dev/null
+++ b/internal/gateway/web/components/sidebar_templ.go
@@ -0,0 +1,40 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.943
+package components
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Sidebar() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/gateway/web/handler.go b/internal/gateway/web/handler.go
new file mode 100644
index 0000000..9510e11
--- /dev/null
+++ b/internal/gateway/web/handler.go
@@ -0,0 +1,163 @@
+package web
+
+import (
+ "net/http"
+
+ "github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
+ "github.com/gin-gonic/gin"
+ "github.com/kubeflow/spark-operator/v2/api/v1beta2"
+ "sigs.k8s.io/yaml"
+
+ "github.com/slackhq/spark-gateway/internal/gateway/application/handler"
+ "github.com/slackhq/spark-gateway/internal/gateway/cluster"
+ "github.com/slackhq/spark-gateway/internal/gateway/web/app"
+ "github.com/slackhq/spark-gateway/pkg/model"
+)
+
+type WebHandler struct {
+ localClusterRepo *cluster.LocalClusterRepo
+ gatewayApplicationService handler.GatewayApplicationService
+ engine *gin.Engine
+ routerGroup *gin.RouterGroup
+}
+
+func NewWebHandler(localClusterRepo *cluster.LocalClusterRepo, gatewayApplicationService handler.GatewayApplicationService, engine *gin.Engine, routerGroup *gin.RouterGroup) *WebHandler {
+ return &WebHandler{
+ localClusterRepo: localClusterRepo,
+ gatewayApplicationService: gatewayApplicationService,
+ engine: engine,
+ routerGroup: routerGroup,
+ }
+}
+
+func (h *WebHandler) RegisterRoutes() {
+ uiGroup := h.routerGroup.Group("/ui")
+
+ ginHtmlRenderer := h.engine.HTMLRender
+ h.engine.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: ginHtmlRenderer}
+
+ uiGroup.GET("/", h.main)
+ uiGroup.GET("/clusters", h.clusters)
+ uiGroup.GET("/applications/:gatewayId/spec", h.applicationSpec)
+
+}
+
+func (h *WebHandler) main(c *gin.Context) {
+ clusters, err := h.localClusterRepo.GetAll()
+ if err != nil {
+ c.Error(err)
+ return
+ }
+
+ selectedCluster := c.Query("cluster")
+ selectedNamespace := c.Query("namespace")
+
+ var applications []*model.GatewayApplicationMeta
+ var namespaces []model.KubeNamespace
+
+ // Get applications if both cluster and namespace are selected
+ if selectedCluster != "" && selectedNamespace != "" {
+ applications, err = h.gatewayApplicationService.List(c, selectedCluster, selectedNamespace, nil)
+ if err != nil {
+ c.Error(err)
+ return
+ }
+ }
+
+ // Get namespaces if cluster is selected
+ if selectedCluster != "" {
+ for _, cluster := range clusters {
+ if cluster.Name == selectedCluster {
+ namespaces = cluster.Namespaces
+ break
+ }
+ }
+ }
+
+ // Get counts of applications in different states across all clusters
+ counts := app.ApplicationCounts{}
+ if err == nil {
+ // Define the states we want to count
+ states := map[string]v1beta2.ApplicationStateType{
+ "submitted": v1beta2.ApplicationStateSubmitted,
+ "running": v1beta2.ApplicationStateRunning,
+ "completed": v1beta2.ApplicationStateCompleted,
+ "failed": v1beta2.ApplicationStateFailed,
+ }
+
+ for _, cluster := range clusters {
+ for _, namespace := range cluster.Namespaces {
+ for stateName, state := range states {
+ apps, err := h.gatewayApplicationService.List(c, cluster.Name, namespace.Name, &state)
+ if err == nil {
+ switch stateName {
+ case "submitted":
+ counts.Submitted += len(apps)
+ case "running":
+ counts.Running += len(apps)
+ case "completed":
+ counts.Completed += len(apps)
+ case "failed":
+ counts.Failed += len(apps)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if c.GetHeader("HX-Request") == "true" {
+ r := gintemplrenderer.New(c, http.StatusOK, app.MainContent(counts, clusters, applications, selectedCluster, selectedNamespace, namespaces))
+ c.Render(http.StatusOK, r)
+ } else {
+ r := gintemplrenderer.New(c, http.StatusOK, app.Main(counts, clusters, applications, selectedCluster, selectedNamespace, namespaces))
+ c.Render(http.StatusOK, r)
+ }
+}
+
+func (h *WebHandler) clusters(c *gin.Context) {
+ clusters, err := h.localClusterRepo.GetAll()
+ if err != nil {
+ c.Error(err)
+ return
+ }
+
+ // Check if this is an HTMX request (partial update)
+ if c.GetHeader("HX-Request") == "true" {
+ r := gintemplrenderer.New(c, http.StatusOK, app.ClustersContent(clusters))
+ c.Render(http.StatusOK, r)
+ } else {
+ // Full page load
+ r := gintemplrenderer.New(c, http.StatusOK, app.Clusters(clusters))
+ c.Render(http.StatusOK, r)
+ }
+}
+
+func (h *WebHandler) applicationSpec(c *gin.Context) {
+ gatewayId := c.Param("gatewayId")
+ if gatewayId == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "gatewayId is required"})
+ return
+ }
+
+ // Get the full application data using the service
+ gatewayApp, err := h.gatewayApplicationService.Get(c, gatewayId)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Convert SparkApplication to YAML
+ yamlData, err := yaml.Marshal(gatewayApp.SparkApplication)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal application to YAML"})
+ return
+ }
+
+ // Return YAML spec only
+ response := gin.H{
+ "spec": string(yamlData),
+ }
+
+ c.JSON(http.StatusOK, response)
+}
diff --git a/internal/sparkManager/application/handler/handler.go b/internal/sparkManager/application/handler/handler.go
index f36cccd..3348aea 100644
--- a/internal/sparkManager/application/handler/handler.go
+++ b/internal/sparkManager/application/handler/handler.go
@@ -18,9 +18,11 @@ package handler
import (
"context"
"fmt"
- "github.com/slackhq/spark-gateway/pkg/model"
"net/http"
"strconv"
+ "strings"
+
+ "github.com/slackhq/spark-gateway/pkg/model"
"github.com/gin-gonic/gin"
"github.com/kubeflow/spark-operator/v2/api/v1beta2"
@@ -32,7 +34,7 @@ import (
type SparkApplicationService interface {
Get(namespace string, name string) (*v1beta2.SparkApplication, error)
- List(namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
Status(namespace string, name string) (*v1beta2.SparkApplicationStatus, error)
Logs(namespace string, name string, tailLines int64) (*string, error)
Create(ctx context.Context, application *v1beta2.SparkApplication) (*v1beta2.SparkApplication, error)
@@ -78,7 +80,19 @@ func (h *SparkApplicationHandler) Get(c *gin.Context) {
}
func (h *SparkApplicationHandler) List(c *gin.Context) {
- appMetaList, err := h.sparkApplicationService.List(c.Param("namespace"))
+
+ _appState := strings.ToUpper(c.Query("appState"))
+ var appState *v1beta2.ApplicationStateType = nil
+ if _appState != "" {
+ state := v1beta2.ApplicationStateType(_appState)
+ if !model.ValidSparkApplicationStatesMap[state] {
+ c.Error(fmt.Errorf("invalid application state: %s", state))
+ return
+ }
+ appState = &state
+ }
+
+ appMetaList, err := h.sparkApplicationService.List(c.Param("namespace"), appState)
if err != nil {
c.Error(err)
diff --git a/internal/sparkManager/application/handler/mocksparkapplicationservice.go b/internal/sparkManager/application/handler/mocksparkapplicationservice.go
index a033fde..03b7af1 100644
--- a/internal/sparkManager/application/handler/mocksparkapplicationservice.go
+++ b/internal/sparkManager/application/handler/mocksparkapplicationservice.go
@@ -29,7 +29,7 @@ var _ SparkApplicationService = &SparkApplicationServiceMock{}
// GetFunc: func(namespace string, name string) (*v1beta2.SparkApplication, error) {
// panic("mock out the Get method")
// },
-// ListFunc: func(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+// ListFunc: func(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
// panic("mock out the List method")
// },
// LogsFunc: func(namespace string, name string, tailLines int64) (*string, error) {
@@ -55,7 +55,7 @@ type SparkApplicationServiceMock struct {
GetFunc func(namespace string, name string) (*v1beta2.SparkApplication, error)
// ListFunc mocks the List method.
- ListFunc func(namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ ListFunc func(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
// LogsFunc mocks the Logs method.
LogsFunc func(namespace string, name string, tailLines int64) (*string, error)
@@ -92,6 +92,8 @@ type SparkApplicationServiceMock struct {
List []struct {
// Namespace is the namespace argument value.
Namespace string
+ // AppState is the appState argument value.
+ AppState *v1beta2.ApplicationStateType
}
// Logs holds details about calls to the Logs method.
Logs []struct {
@@ -231,19 +233,21 @@ func (mock *SparkApplicationServiceMock) GetCalls() []struct {
}
// List calls ListFunc.
-func (mock *SparkApplicationServiceMock) List(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (mock *SparkApplicationServiceMock) List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
if mock.ListFunc == nil {
panic("SparkApplicationServiceMock.ListFunc: method is nil but SparkApplicationService.List was just called")
}
callInfo := struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
}{
Namespace: namespace,
+ AppState: appState,
}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
- return mock.ListFunc(namespace)
+ return mock.ListFunc(namespace, appState)
}
// ListCalls gets all the calls that were made to List.
@@ -252,9 +256,11 @@ func (mock *SparkApplicationServiceMock) List(namespace string) ([]*model.SparkM
// len(mockedSparkApplicationService.ListCalls())
func (mock *SparkApplicationServiceMock) ListCalls() []struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
} {
var calls []struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
}
mock.lockList.RLock()
calls = mock.calls.List
diff --git a/internal/sparkManager/application/repository/repository.go b/internal/sparkManager/application/repository/repository.go
index f9e9bb0..332706e 100644
--- a/internal/sparkManager/application/repository/repository.go
+++ b/internal/sparkManager/application/repository/repository.go
@@ -18,10 +18,11 @@ package repository
import (
"context"
"fmt"
- "github.com/slackhq/spark-gateway/pkg/model"
"net/http"
"time"
+ "github.com/slackhq/spark-gateway/pkg/model"
+
"github.com/slackhq/spark-gateway/pkg/gatewayerrors"
"github.com/slackhq/spark-gateway/pkg/kube"
"github.com/slackhq/spark-gateway/pkg/util"
@@ -58,7 +59,7 @@ func (k *SparkApplicationRepository) Get(namespace string, name string) (*v1beta
}
-func (k *SparkApplicationRepository) List(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (k *SparkApplicationRepository) List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
sparkApps, err := k.controller.SparkLister.SparkApplications(namespace).List(labels.Everything())
@@ -69,9 +70,13 @@ func (k *SparkApplicationRepository) List(namespace string) ([]*model.SparkManag
var appMetaList []*model.SparkManagerApplicationMeta
for _, sparkApp := range sparkApps {
+ if appState != nil && sparkApp.Status.AppState.State != *appState {
+ continue
+ }
sparkAppMeta := model.NewSparkManagerApplicationMeta(sparkApp)
appMetaList = append(appMetaList, sparkAppMeta)
}
+
return appMetaList, nil
}
diff --git a/internal/sparkManager/application/service/mocksparkapplicationrepository.go b/internal/sparkManager/application/service/mocksparkapplicationrepository.go
index dbfa8df..f1c8f8c 100644
--- a/internal/sparkManager/application/service/mocksparkapplicationrepository.go
+++ b/internal/sparkManager/application/service/mocksparkapplicationrepository.go
@@ -32,7 +32,7 @@ var _ SparkApplicationRepository = &SparkApplicationRepositoryMock{}
// GetLogsFunc: func(namespace string, name string, tailLines int64) (*string, error) {
// panic("mock out the GetLogs method")
// },
-// ListFunc: func(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+// ListFunc: func(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
// panic("mock out the List method")
// },
// }
@@ -55,7 +55,7 @@ type SparkApplicationRepositoryMock struct {
GetLogsFunc func(namespace string, name string, tailLines int64) (*string, error)
// ListFunc mocks the List method.
- ListFunc func(namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ ListFunc func(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
// calls tracks calls to the methods.
calls struct {
@@ -95,6 +95,8 @@ type SparkApplicationRepositoryMock struct {
List []struct {
// Namespace is the namespace argument value.
Namespace string
+ // AppState is the appState argument value.
+ AppState *v1beta2.ApplicationStateType
}
}
lockCreate sync.RWMutex
@@ -257,19 +259,21 @@ func (mock *SparkApplicationRepositoryMock) GetLogsCalls() []struct {
}
// List calls ListFunc.
-func (mock *SparkApplicationRepositoryMock) List(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (mock *SparkApplicationRepositoryMock) List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
if mock.ListFunc == nil {
panic("SparkApplicationRepositoryMock.ListFunc: method is nil but SparkApplicationRepository.List was just called")
}
callInfo := struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
}{
Namespace: namespace,
+ AppState: appState,
}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
- return mock.ListFunc(namespace)
+ return mock.ListFunc(namespace, appState)
}
// ListCalls gets all the calls that were made to List.
@@ -278,9 +282,11 @@ func (mock *SparkApplicationRepositoryMock) List(namespace string) ([]*model.Spa
// len(mockedSparkApplicationRepository.ListCalls())
func (mock *SparkApplicationRepositoryMock) ListCalls() []struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
} {
var calls []struct {
Namespace string
+ AppState *v1beta2.ApplicationStateType
}
mock.lockList.RLock()
calls = mock.calls.List
diff --git a/internal/sparkManager/application/service/service.go b/internal/sparkManager/application/service/service.go
index 36b688c..b010383 100644
--- a/internal/sparkManager/application/service/service.go
+++ b/internal/sparkManager/application/service/service.go
@@ -32,7 +32,7 @@ import (
type SparkApplicationRepository interface {
Get(namespace string, name string) (*v1beta2.SparkApplication, error)
- List(namespace string) ([]*model.SparkManagerApplicationMeta, error)
+ List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error)
GetLogs(namespace string, name string, tailLines int64) (*string, error)
Create(ctx context.Context, application *v1beta2.SparkApplication) (*v1beta2.SparkApplication, error)
Delete(ctx context.Context, namespace string, name string) error
@@ -59,9 +59,9 @@ func (s *SparkApplicationService) Get(namespace string, name string) (*v1beta2.S
return sparkApp, nil
}
-func (s *SparkApplicationService) List(namespace string) ([]*model.SparkManagerApplicationMeta, error) {
+func (s *SparkApplicationService) List(namespace string, appState *v1beta2.ApplicationStateType) ([]*model.SparkManagerApplicationMeta, error) {
- appMetaList, err := s.sparkApplicationRepository.List(namespace)
+ appMetaList, err := s.sparkApplicationRepository.List(namespace, appState)
if err != nil {
return nil, gatewayerrors.NewFrom(err)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..48e341a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3 @@
+{
+ "lockfileVersion": 1
+}
diff --git a/pkg/model/application.go b/pkg/model/application.go
index 73a07a7..ebd6724 100644
--- a/pkg/model/application.go
+++ b/pkg/model/application.go
@@ -16,11 +16,13 @@
package model
import (
+ "errors"
"fmt"
+ "strings"
+
"github.com/google/uuid"
"github.com/kubeflow/spark-operator/v2/api/v1beta2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "strings"
)
const GATEWAY_USER_LABEL = "spark-gateway/user"
@@ -78,6 +80,14 @@ func ParseGatewayIdUUID(gatewayId string) (*uuid.UUID, error) {
return nil, fmt.Errorf("error parsing gatewayId (%s). Format must be 'cluster-namespace-uuid'", gatewayId)
}
+func GetUser(sparkAppLabels map[string]string) (*string, error) {
+ user, ok := sparkAppLabels[GATEWAY_USER_LABEL]
+ if !ok {
+ return nil, errors.New("no gateway user associated with this application, possibly not created through spark-gateway")
+ }
+ return &user, nil
+}
+
type SparkManagerApplicationMeta struct {
ObjectMeta metav1.ObjectMeta `json:"metadata"`
// All fields below come from v1beta2.SparkApplicationStatus
@@ -95,6 +105,7 @@ type SparkManagerApplicationMeta struct {
type GatewayApplicationMeta struct {
SparkAppMeta SparkManagerApplicationMeta `json:"sparkAppMetadata"`
Cluster string `json:"cluster"`
+ User string `json:"user"`
}
func NewSparkManagerApplicationMeta(sparkApp *v1beta2.SparkApplication) *SparkManagerApplicationMeta {
@@ -121,9 +132,24 @@ func NewSparkManagerApplicationMeta(sparkApp *v1beta2.SparkApplication) *SparkMa
}
-func NewGatewayApplicationMeta(smAppMeta *SparkManagerApplicationMeta, cluster string) *GatewayApplicationMeta {
+func NewGatewayApplicationMeta(smAppMeta *SparkManagerApplicationMeta, cluster string, user string) *GatewayApplicationMeta {
return &GatewayApplicationMeta{
SparkAppMeta: *smAppMeta,
Cluster: cluster,
+ User: user,
}
}
+
+var ValidSparkApplicationStatesMap = map[v1beta2.ApplicationStateType]bool{
+ v1beta2.ApplicationStateNew: true,
+ v1beta2.ApplicationStateSubmitted: true,
+ v1beta2.ApplicationStateRunning: true,
+ v1beta2.ApplicationStateCompleted: true,
+ v1beta2.ApplicationStateFailed: true,
+ v1beta2.ApplicationStateFailedSubmission: true,
+ v1beta2.ApplicationStatePendingRerun: true,
+ v1beta2.ApplicationStateInvalidating: true,
+ v1beta2.ApplicationStateSucceeding: true,
+ v1beta2.ApplicationStateFailing: true,
+ v1beta2.ApplicationStateUnknown: true,
+}