fix: add ui-path for kk web (#2670)

Signed-off-by: joyceliu <joyceliu@yunify.com>
This commit is contained in:
liujian 2025-07-25 11:20:41 +08:00 committed by GitHub
parent 5ff30bff45
commit 7b61dafb95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 154 additions and 84 deletions

View File

@ -20,6 +20,7 @@ type KubeKeyWebOptions struct {
Workdir string // Workdir specifies the base directory for KubeKey
SchemaPath string
UIPath string
}
// NewKubeKeyWebOptions creates and returns a new KubeKeyWebOptions instance with default values
@ -46,6 +47,7 @@ func (o *KubeKeyWebOptions) Flags() cliflag.NamedFlagSets {
wfs.IntVar(&o.Port, "port", o.Port, fmt.Sprintf("the server port of kubekey web default is: %d", o.Port))
wfs.StringVar(&o.Workdir, "workdir", o.Workdir, "the base Dir for kubekey. Default current dir. ")
wfs.StringVar(&o.SchemaPath, "schema-path", o.SchemaPath, "the json schema dir path to render web ui.")
wfs.StringVar(&o.UIPath, "ui-path", o.SchemaPath, "the web ui package path.")
return fss
}

View File

@ -42,6 +42,7 @@ func newWebCommand() *cobra.Command {
Workdir: o.Workdir,
Port: o.Port,
SchemaPath: o.SchemaPath,
UIPath: o.UIPath,
Client: client,
Config: restconfig,
}).Run(cmd.Context())

View File

@ -7,9 +7,3 @@ import "embed"
//
//go:embed swagger-ui
var Swagger embed.FS
// WebUI embeds the web directory containing the static web UI assets
// This allows serving the web UI directly from the binary without needing external files
//
//go:embed all:web
var WebUI embed.FS

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>

View File

@ -64,6 +64,7 @@ type WebManagerOptions struct {
Workdir string
Port int
SchemaPath string
UIPath string
ctrlclient.Client
*rest.Config
}
@ -74,6 +75,7 @@ func NewWebManager(o WebManagerOptions) Manager {
workdir: o.Workdir,
port: o.Port,
schemaPath: o.SchemaPath,
uiPath: o.UIPath,
Client: o.Client,
Config: o.Config,
}

View File

@ -23,6 +23,7 @@ type webManager struct {
workdir string
schemaPath string
uiPath string
ctrlclient.Client
*rest.Config
@ -40,7 +41,7 @@ func (m webManager) Run(ctx context.Context) error {
// openapi
Add(web.NewSwaggerUIService()).
Add(web.NewAPIService(container.RegisteredWebServices())).
Add(web.NewUIService())
Add(web.NewUIService(m.uiPath))
server := &http.Server{
Addr: fmt.Sprintf(":%d", m.port),

View File

@ -19,6 +19,11 @@ package api
const (
// SchemaLabelSubfix is the label key used to indicate which schema a playbook belongs to.
SchemaLabelSubfix = "kubekey.kubesphere.io/schema"
// SchemaProductFile is the predefined file name for storing product information.
SchemaProductFile = "product.json"
// SchemaConfigFile is the predefined file name for caching configuration.
SchemaConfigFile = "config.json"
)
const (
@ -70,24 +75,26 @@ type InventoryHostGroups struct {
Index int `json:"index"` // the index of groups which hosts belong to
}
// SchemaTable represents schema metadata for a resource.
// It includes fields such as name, type, title, description, version, namespace, logo, priority, and associated playbooks.
// The Playbook field is a slice of SchemaTablePlaybook, each representing a playbook reference.
type SchemaTable struct {
Name string `json:"name"` // Name of schema, defined by filename
SchemaType string `json:"schemaType"` // Type of the schema (e.g., CRD, built-in)
Title string `json:"title"` // Title of the schema
Description string `json:"description"` // Description of the schema
Version string `json:"version"` // Version of the schema
Namespace string `json:"namespace"` // Namespace of the schema
Logo string `json:"logo"` // Logo URL or identifier
Priority int `json:"priority"` // Priority for display or ordering
Playbook SchemaTablePlaybook `json:"playbook"` // List of reference playbooks
PlaybookPath map[string]string `json:"playbookPath,omitempty"` // PlaybookPath for current schema
// SchemaFileDataSchema represents the metadata section of a schema file as used in the API layer.
// It contains the main data schema metadata such as title, description, version, namespace, logo, and priority.
type SchemaFileDataSchema struct {
Title string `json:"title"` // Title of the schema
Description string `json:"description"` // Description of the schema
Version string `json:"version"` // Version of the schema
Namespace string `json:"namespace"` // Namespace of the schema
Logo string `json:"logo"` // Logo URL or identifier
Priority int `json:"priority"` // Priority for display or ordering
}
// SchemaTablePlaybook represents a reference to a playbook associated with a schema.
// It includes the playbook's name, namespace, and phase.
// SchemaFile represents the structure of a schema file as used in the API layer.
// It contains the main data schema metadata and a mapping of playbook labels to their paths.
type SchemaFile struct {
DataSchema SchemaFileDataSchema `json:"dataSchema"` // Metadata of the schema file
PlaybookPath map[string]string `json:"playbookPath"` // Mapping of playbook labels to their file paths
}
// SchemaTablePlaybook represents the details of a playbook associated with a schema in the response table.
// It includes the path, name, namespace, phase, and result of the playbook.
type SchemaTablePlaybook struct {
Path string `json:"path"` // Path of playbook template.
Name string `json:"name"` // Name of the playbook
@ -96,6 +103,20 @@ type SchemaTablePlaybook struct {
Result any `json:"result"` // Result of the playbook
}
// SchemaTable represents the response table constructed from a schema file.
// It includes metadata fields such as name, title, description, version, namespace, logo, and priority.
// The Playbook field is a map of playbook labels to SchemaTablePlaybook, each representing a playbook reference.
type SchemaTable struct {
Name string `json:"name"` // Name of schema, defined by filename
Title string `json:"title"` // Title of the schema
Description string `json:"description"` // Description of the schema
Version string `json:"version"` // Version of the schema
Namespace string `json:"namespace"` // Namespace of the schema
Logo string `json:"logo"` // Logo URL or identifier
Priority int `json:"priority"` // Priority for display or ordering
Playbook map[string]SchemaTablePlaybook `json:"playbook"` // Map of playbook labels to playbook details
}
// IPTable represents an IP address entry and its SSH status information.
// It indicates whether the IP is a localhost, if SSH is reachable, and if SSH authorization is present.
type IPTable struct {
@ -105,3 +126,27 @@ type IPTable struct {
SSHReachable bool `json:"sshReachable"` // Whether SSH port is reachable on this IP
SSHAuthorized bool `json:"sshAuthorized"` // Whether SSH is authorized for this IP
}
// SchemaFile2Table converts a SchemaFile and its filename into a SchemaTable structure.
// It initializes the SchemaTable fields from the SchemaFile's DataSchema and sets up the Playbook map
// with playbook labels and their corresponding paths. Other playbook fields are left empty for later population.
func SchemaFile2Table(schemaFile SchemaFile, filename string) SchemaTable {
table := SchemaTable{
Name: filename,
Title: schemaFile.DataSchema.Title,
Description: schemaFile.DataSchema.Description,
Version: schemaFile.DataSchema.Version,
Namespace: schemaFile.DataSchema.Namespace,
Logo: schemaFile.DataSchema.Logo,
Priority: schemaFile.DataSchema.Priority,
Playbook: make(map[string]SchemaTablePlaybook),
}
// Populate the Playbook map with playbook labels and their paths from the schema file.
for k, v := range schemaFile.PlaybookPath {
table.Playbook[k] = SchemaTablePlaybook{
Path: v,
}
}
return table
}

View File

@ -3,6 +3,7 @@ package web
import (
"io/fs"
"net/http"
"os"
"strings"
"github.com/cockroachdb/errors"
@ -20,20 +21,16 @@ import (
// - Serves "/" with index.html
// - Serves static assets (e.g., .js, .css, .png) from the embedded web directory
// - Forwards all other non-API paths to index.html for SPA client-side routing
func NewUIService() *restful.WebService {
func NewUIService(path string) *restful.WebService {
ws := new(restful.WebService)
ws.Path("/")
// Create a sub-filesystem for the embedded web UI assets
subFS, err := fs.Sub(config.WebUI, "web")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(subFS))
fileServer := http.FileServer(http.FS(os.DirFS(path)))
// Serve the root path "/" with index.html
ws.Route(ws.GET("").To(func(req *restful.Request, resp *restful.Response) {
data, err := fs.ReadFile(subFS, "index.html")
data, err := fs.ReadFile(os.DirFS(path), "index.html")
if err != nil {
_ = resp.WriteError(http.StatusNotFound, err)
return
@ -64,7 +61,7 @@ func NewUIService() *restful.WebService {
}
// For all other paths, serve index.html (SPA client-side routing)
data, err := fs.ReadFile(subFS, "index.html")
data, err := fs.ReadFile(os.DirFS(path), "index.html")
if err != nil {
_ = resp.WriteError(http.StatusInternalServerError, err)
return

View File

@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
@ -58,15 +59,22 @@ func NewSchemaService(rootPath string, workdir string, client ctrlclient.Client)
ws.Route(ws.GET("/schema").To(h.allSchema).
Doc("list all schema as table").
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}).
Param(ws.QueryParameter("schemaType", "the type of schema json").Required(false)).
Param(ws.QueryParameter("playbookLabel", "the reference playbook of schema. eg: \"install.kubekey.kubesphere.io/schema\", \"check.kubekey.kubesphere.io/schema\" "+
"if empty will not return any reference playbook").Required(false)).
Param(ws.PathParameter("namespace", "the namespace of the playbook").Required(false).DefaultValue("default")).
Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("false")).
Param(ws.QueryParameter(query.ParameterOrderBy, "sort parameters, e.g. orderBy=priority")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[api.SchemaTable]{}))
ws.Route(ws.POST("/schema/config").To(h.storeConfig).
Doc("storing user-defined configuration information").
Reads(struct{}{}).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}))
ws.Route(ws.GET("/schema/config").To(h.configInfo).
Doc("get user-defined configuration information").
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}))
return ws
}
@ -82,6 +90,35 @@ type schemaHandler struct {
client ctrlclient.Client
}
func (h schemaHandler) configInfo(request *restful.Request, response *restful.Response) {
file, err := os.Open(filepath.Join(h.rootPath, api.SchemaConfigFile))
if err != nil {
if os.IsNotExist(err) {
_ = response.WriteError(http.StatusNotFound, err)
} else {
_ = response.WriteError(http.StatusInternalServerError, err)
}
return
}
defer file.Close()
_, _ = io.Copy(response.ResponseWriter, file)
}
func (h schemaHandler) storeConfig(request *restful.Request, response *restful.Response) {
file, err := os.OpenFile(filepath.Join(h.rootPath, api.SchemaConfigFile), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
_ = response.WriteError(http.StatusInternalServerError, err)
return
}
defer file.Close()
_, err = io.Copy(file, request.Request.Body)
if err != nil {
_ = response.WriteError(http.StatusInternalServerError, err)
return
}
}
func (h schemaHandler) listIP(request *restful.Request, response *restful.Response) {
queryParam := query.ParseQueryParameter(request)
cidr, ok := queryParam.Filters["cidr"]
@ -193,8 +230,7 @@ func (h schemaHandler) schemaInfo(request *restful.Request, response *restful.Re
// It supports filtering, sorting, and pagination via query parameters.
func (h schemaHandler) allSchema(request *restful.Request, response *restful.Response) {
queryParam := query.ParseQueryParameter(request)
playbookLabel := string(queryParam.Filters["playbookLabel"])
// Get all entries in the rootPath directory.
// Read all entries in the rootPath directory.
entries, err := os.ReadDir(h.rootPath)
if err != nil {
api.HandleBadRequest(response, request, err)
@ -202,9 +238,10 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res
}
schemaTable := make([]api.SchemaTable, 0)
for _, entry := range entries {
if entry.IsDir() || // Skip directories.
!strings.HasSuffix(entry.Name(), ".json") || // Only process files with .json suffix.
entry.Name() == "product.json" { // "product.json" is agreed file name
// Skip directories, non-JSON files, and special schema files.
if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".json") ||
entry.Name() == api.SchemaProductFile || entry.Name() == api.SchemaConfigFile {
continue
}
// Read the JSON file.
@ -213,50 +250,49 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res
api.HandleBadRequest(response, request, err)
return
}
schema := api.SchemaTable{Name: entry.Name()}
var schemaFile api.SchemaFile
// Unmarshal the JSON data into a SchemaTable struct.
if err := json.Unmarshal(data, &schema); err != nil {
if err := json.Unmarshal(data, &schemaFile); err != nil {
api.HandleBadRequest(response, request, err)
return
}
// get reference playbook
if playbookLabel != "" {
playbookList := &kkcorev1.PlaybookList{}
if err := h.client.List(request.Request.Context(), playbookList, ctrlclient.MatchingLabels{
playbookLabel: entry.Name(),
}); err != nil {
api.HandleBadRequest(response, request, err)
return
}
switch len(playbookList.Items) {
case 0: // skip
case 1:
item := &playbookList.Items[0]
var result any
if len(item.Status.Result.Raw) != 0 {
if err := json.Unmarshal(item.Status.Result.Raw, &result); err != nil {
api.HandleBadRequest(response, request, errors.Errorf("failed to unmarshal result from playbook of schema %q", schema.Name))
// Get all playbooks in the given namespace.
playbookList := &kkcorev1.PlaybookList{}
if err := h.client.List(request.Request.Context(), playbookList, ctrlclient.InNamespace(request.PathParameter("namespace"))); err != nil {
api.HandleBadRequest(response, request, err)
return
}
schema := api.SchemaFile2Table(schemaFile, entry.Name())
// For each playbook, if it matches a label in schema.Playbook and the label value equals schema.Name, add its info.
for _, playbook := range playbookList.Items {
for label, schemaName := range playbook.Labels {
// Only process playbooks whose label is defined in schema.Playbook and value matches schema.Name.
if _, ok := schema.Playbook[label]; ok && schemaName == schema.Name {
// If a playbook for this label already exists, return an error.
if schema.Playbook[label].Name != "" {
api.HandleBadRequest(response, request, errors.Errorf("schema %q has multiple playbooks of label %q", entry.Name(), label))
return
}
var result any
// If the playbook has a result, unmarshal it.
if len(playbook.Status.Result.Raw) != 0 {
if err := json.Unmarshal(playbook.Status.Result.Raw, &result); err != nil {
api.HandleBadRequest(response, request, errors.Errorf("failed to unmarshal result from playbook of schema %q", schema.Name))
return
}
}
// Fill in playbook info for this label.
schema.Playbook[label] = api.SchemaTablePlaybook{
Path: schema.Playbook[label].Path,
Name: playbook.Name,
Namespace: playbook.Namespace,
Phase: string(playbook.Status.Phase),
Result: result,
}
}
schema.Playbook = api.SchemaTablePlaybook{
Path: schema.PlaybookPath[playbookLabel],
Name: item.Name,
Namespace: item.Namespace,
Phase: string(item.Status.Phase),
Result: result,
}
default:
playbookNames := make([]string, 0, len(playbookList.Items))
for _, playbook := range playbookList.Items {
playbookNames = append(playbookNames, playbook.Name)
}
api.HandleBadRequest(response, request, errors.Errorf("schema %q has multiple playbooks: %q", entry.Name(), playbookNames))
return
}
}
// clear PlaybookPath
schema.PlaybookPath = nil
// Add the processed schema to the schemaTable slice.
schemaTable = append(schemaTable, schema)
}
// less is a comparison function for sorting SchemaTable items by a given field.
@ -269,6 +305,7 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res
case reflect.Int, reflect.Int64:
return leftVal.Int() > rightVal.Int()
default:
// If the field is not a string or int, sort by Priority as a fallback.
return left.Priority > right.Priority
}
}
@ -290,6 +327,7 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res
}
// Use the DefaultList function to apply filtering, sorting, and pagination.
// The results variable contains the filtered, sorted, and paginated schemaTable.
results := query.DefaultList(schemaTable, queryParam, less, filter)
_ = response.WriteEntity(results)
}