1
0
mirror of https://github.com/cnrancher/kube-explorer.git synced 2025-05-11 09:28:30 +00:00

refactor: UI resource logic

- Support embed api-ui resources
- The ui-path arg will be applied if provided. Also applied to api-ui resource files
This commit is contained in:
Yuxing Deng 2024-07-23 16:24:03 +08:00
parent 004e4751c8
commit eacc47482e
18 changed files with 452 additions and 228 deletions

View File

@ -20,7 +20,8 @@ RUN if [ "${ARCH}" == "amd64" ]; then \
fi
COPY --from=tools /app/release-notary /usr/local/bin/
ENV CATTLE_DASHBOARD_UI_VERSION="v2.8.0-kube-explorer-ui-rc3"
ENV CATTLE_API_UI_VERSION="1.1.11"
ENV DAPPER_ENV REPO TAG DRONE_TAG CROSS GOPROXY SKIP_COMPRESS GITHUB_REPOSITORY GITHUB_TOKEN
ENV DAPPER_SOURCE /go/src/github.com/cnrancher/kube-explorer
ENV DAPPER_OUTPUT ./bin ./dist

View File

@ -6,6 +6,7 @@ import (
var InsecureSkipTLSVerify bool
var SystemDefaultRegistry string
var APIUIVersion = "1.1.11"
var ShellPodImage string
@ -24,5 +25,11 @@ func Flags() []cli.Flag {
Destination: &ShellPodImage,
Value: "rancher/shell:v0.2.1-rc.7",
},
cli.StringFlag{
Name: "apiui-version",
Hidden: true,
Destination: &APIUIVersion,
Value: APIUIVersion,
},
}
}

11
internal/config/steve.go Normal file
View File

@ -0,0 +1,11 @@
package config
import (
"github.com/rancher/steve/pkg/debug"
stevecli "github.com/rancher/steve/pkg/server/cli"
)
var (
Steve stevecli.Config
Debug debug.Config
)

View File

@ -17,6 +17,7 @@ import (
"github.com/cnrancher/kube-explorer/internal/config"
"github.com/cnrancher/kube-explorer/internal/resources/cluster"
"github.com/cnrancher/kube-explorer/internal/ui"
"github.com/cnrancher/kube-explorer/internal/version"
)
func ToServer(ctx context.Context, c *cli.Config, sqlCache bool) (*server.Server, error) {
@ -48,10 +49,15 @@ func ToServer(ctx context.Context, c *cli.Config, sqlCache bool) (*server.Server
return nil, err
}
ui, apiui := ui.New(&ui.Options{
ReleaseSetting: version.IsRelease,
Path: func() string { return c.UIPath },
})
steveServer, err := server.New(ctx, restConfig, &server.Options{
AuthMiddleware: auth,
Controllers: controllers,
Next: ui.New(c.UIPath),
Next: ui,
SQLCache: sqlCache,
// router needs to hack here
Router: func(h router.Handlers) http.Handler {
@ -62,6 +68,8 @@ func ToServer(ctx context.Context, c *cli.Config, sqlCache bool) (*server.Server
return nil, err
}
steveServer.APIServer.CustomAPIUIResponseWriter(apiui.CSS(), apiui.JS(), func() string { return config.APIUIVersion })
// registrer local cluster
if err := cluster.Register(ctx, steveServer, c.Context); err != nil {
return steveServer, err

55
internal/ui/apiui.go Normal file
View File

@ -0,0 +1,55 @@
package ui
import "github.com/rancher/apiserver/pkg/writer"
type APIUI struct {
offline StringSetting
release BoolSetting
embed bool
}
func apiUI(opt *Options) APIUI {
var rtn = APIUI{
offline: opt.Offline,
release: opt.ReleaseSetting,
embed: true,
}
if rtn.offline == nil {
rtn.offline = StaticSetting("dynamic")
}
if rtn.release == nil {
rtn.release = StaticSetting(false)
}
for _, file := range []string{
"ui/api-ui/ui.min.css",
"ui/api-ui/ui.min.js",
} {
if _, err := staticContent.Open(file); err != nil {
rtn.embed = false
break
}
}
return rtn
}
func (a APIUI) content(name string) writer.StringGetter {
return func() (rtn string) {
switch a.offline() {
case "dynamic":
if !a.release() && !a.embed {
return ""
}
case "false":
return ""
}
return name
}
}
func (a APIUI) CSS() writer.StringGetter {
return a.content("/api-ui/ui.min.css")
}
func (a APIUI) JS() writer.StringGetter {
return a.content("/api-ui/ui.min.js")
}

View File

@ -0,0 +1,24 @@
package content
import (
"io/fs"
"net/http"
)
type fsFunc func(name string) (fs.File, error)
func (f fsFunc) Open(name string) (fs.File, error) {
return f(name)
}
type fsContent interface {
ToFileServer(basePaths ...string) http.Handler
Open(name string) (fs.File, error)
}
type Handler interface {
ServeAssets(middleware func(http.Handler) http.Handler, hext http.Handler) http.Handler
ServeFaviconDashboard() http.Handler
GetIndex() ([]byte, error)
Refresh()
}

View File

@ -0,0 +1,97 @@
package content
import (
"bytes"
"crypto/tls"
"errors"
"io"
"net/http"
"sync"
)
const (
defaultIndex = "https://releases.rancher.com/dashboard/latest/index.html"
)
func NewExternal(getIndex func() string) Handler {
return &externalIndexHandler{
getIndexFunc: getIndex,
}
}
var (
insecureClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
_ Handler = &externalIndexHandler{}
)
type externalIndexHandler struct {
sync.RWMutex
getIndexFunc func() string
current string
downloadSuccess *bool
}
func (u *externalIndexHandler) ServeAssets(_ func(http.Handler) http.Handler, next http.Handler) http.Handler {
return next
}
func (u *externalIndexHandler) ServeFaviconDashboard() http.Handler {
return http.NotFoundHandler()
}
func (u *externalIndexHandler) GetIndex() ([]byte, error) {
if u.canDownload() {
var buffer bytes.Buffer
if err := serveIndex(&buffer, u.current); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
return nil, errors.New("external index is not available")
}
func serveIndex(resp io.Writer, url string) error {
r, err := insecureClient.Get(url)
if err != nil {
return err
}
defer r.Body.Close()
_, err = io.Copy(resp, r.Body)
return err
}
func (u *externalIndexHandler) canDownload() bool {
u.RLock()
rtn := u.downloadSuccess
u.RUnlock()
if rtn != nil {
return *rtn
}
return u.refresh()
}
func (u *externalIndexHandler) refresh() bool {
u.Lock()
defer u.RUnlock()
u.current = u.getIndexFunc()
if u.current == "" {
u.current = defaultIndex
}
t := serveIndex(io.Discard, u.current) == nil
u.downloadSuccess = &t
return t
}
func (u *externalIndexHandler) Refresh() {
_ = u.refresh()
}

71
internal/ui/content/fs.go Normal file
View File

@ -0,0 +1,71 @@
package content
import (
"io"
"net/http"
"path/filepath"
"sync"
)
var _ Handler = &handler{}
func newFS(content fsContent) Handler {
return &handler{
content: content,
cacheFS: &sync.Map{},
}
}
type handler struct {
content fsContent
cacheFS *sync.Map
}
func (h *handler) pathExist(path string) bool {
_, err := h.content.Open(path)
return err == nil
}
func (h *handler) serveContent(basePaths ...string) http.Handler {
key := filepath.Join(basePaths...)
if rtn, ok := h.cacheFS.Load(key); ok {
return rtn.(http.Handler)
}
rtn := h.content.ToFileServer(basePaths...)
h.cacheFS.Store(key, rtn)
return rtn
}
func (h *handler) Refresh() {
h.cacheFS.Range(func(key, _ any) bool {
h.cacheFS.Delete(key)
return true
})
}
func (h *handler) ServeAssets(middleware func(http.Handler) http.Handler, next http.Handler) http.Handler {
assets := middleware(h.serveContent())
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h.pathExist(r.URL.Path) {
assets.ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
func (h *handler) ServeFaviconDashboard() http.Handler {
return h.serveContent("dashboard")
}
func (h *handler) GetIndex() ([]byte, error) {
path := filepath.Join("dashboard", "index.html")
f, err := h.content.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}

View File

@ -0,0 +1,43 @@
package content
import (
"embed"
"io/fs"
"net/http"
"path/filepath"
)
func NewEmbedded(staticContent embed.FS, prefix string) Handler {
return newFS(&embedFS{
pathPrefix: prefix,
staticContent: staticContent,
})
}
var _ fsContent = &embedFS{}
type embedFS struct {
pathPrefix string
staticContent embed.FS
}
// Open implements fsContent.
func (e *embedFS) Open(name string) (fs.File, error) {
return e.staticContent.Open(joinEmbedFilepath(e.pathPrefix, name))
}
// ToFileServer implements fsContent.
func (e *embedFS) ToFileServer(basePaths ...string) http.Handler {
handler := fsFunc(func(name string) (fs.File, error) {
assetPath := joinEmbedFilepath(joinEmbedFilepath(basePaths...), name)
return e.Open(assetPath)
})
return http.FileServer(http.FS(handler))
}
func (e *embedFS) Refresh() error { return nil }
func joinEmbedFilepath(paths ...string) string {
return filepath.ToSlash(filepath.Join(paths...))
}

View File

@ -0,0 +1,41 @@
package content
import (
"errors"
"io/fs"
"net/http"
"path/filepath"
)
func NewFilepath(getPath func() string) Handler {
return newFS(&filepathFS{
getPath: getPath,
})
}
var _ fsContent = &filepathFS{}
type filepathFS struct {
getPath func() string
}
func (f *filepathFS) ToFileServer(basePaths ...string) http.Handler {
root := f.getPath()
if root == "" {
return http.NotFoundHandler()
}
path := filepath.Join(append([]string{string(root)}, basePaths...)...)
return http.FileServer(http.Dir(path))
}
func (f *filepathFS) Open(name string) (fs.File, error) {
root := f.getPath()
if root == "" {
return nil, errors.New("filepath fs is not ready")
}
return http.Dir(root).Open(name)
}
func (f *filepathFS) Refresh() error {
return nil
}

7
internal/ui/dev.go Normal file
View File

@ -0,0 +1,7 @@
//go:build !embed
package ui
import "embed"
var staticContent embed.FS

View File

@ -4,88 +4,9 @@ package ui
import (
"embed"
"io"
"io/fs"
"net/http"
"path/filepath"
"github.com/sirupsen/logrus"
)
// content holds our static web server content.
//
//go:embed all:ui/*
var staticContent embed.FS
type fsFunc func(name string) (fs.File, error)
func (f fsFunc) Open(name string) (fs.File, error) {
return f(name)
}
func pathExist(path string) bool {
_, err := staticContent.Open(path)
return err == nil
}
func openFile(path string) (fs.File, error) {
file, err := staticContent.Open(path)
if err != nil {
logrus.Errorf("openEmbedFile %s err: %v", path, err)
}
return file, err
}
func serveEmbed(basePaths ...string) http.Handler {
handler := fsFunc(func(name string) (fs.File, error) {
logrus.Debugf("serveEmbed name: %s", name)
assetPath := joinEmbedFilepath(append(basePaths, name)...)
logrus.Debugf("serveEmbed final path: %s", assetPath)
return openFile(assetPath)
})
return http.FileServer(http.FS(handler))
}
func serveEmbedIndex(basePath string) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
path := joinEmbedFilepath(basePath, "dashboard", "index.html")
logrus.Debugf("serveEmbedIndex : %s", path)
f, _ := staticContent.Open(path)
io.Copy(rw, f)
f.Close()
})
}
func (u *Handler) ServeAsset() http.Handler {
return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
serveEmbed(u.pathSetting()).ServeHTTP(rw, req)
}))
}
func (u *Handler) ServeFaviconDashboard() http.Handler {
return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
serveEmbed(u.pathSetting(), "dashboard").ServeHTTP(rw, req)
}))
}
func (u *Handler) IndexFileOnNotFound() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
path := joinEmbedFilepath(u.pathSetting(), req.URL.Path)
if pathExist(path) {
u.ServeAsset().ServeHTTP(rw, req)
} else {
u.IndexFile().ServeHTTP(rw, req)
}
})
}
func (u *Handler) IndexFile() http.Handler {
return u.indexMiddleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
serveEmbedIndex(u.pathSetting()).ServeHTTP(rw, req)
}))
}
func joinEmbedFilepath(paths ...string) string {
return filepath.ToSlash(filepath.Join(paths...))
}

View File

@ -1,42 +0,0 @@
//go:build !embed
package ui
import (
"net/http"
"os"
"path/filepath"
)
func (u *Handler) ServeAsset() http.Handler {
return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
http.FileServer(http.Dir(u.pathSetting())).ServeHTTP(rw, req)
}))
}
func (u *Handler) ServeFaviconDashboard() http.Handler {
return u.middleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
http.FileServer(http.Dir(filepath.Join(u.pathSetting(), "dashboard"))).ServeHTTP(rw, req)
}))
}
func (u *Handler) IndexFileOnNotFound() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// we ignore directories here because we want those to come from the CDN when running in that mode
if stat, err := os.Stat(filepath.Join(u.pathSetting(), req.URL.Path)); err == nil && !stat.IsDir() {
u.ServeAsset().ServeHTTP(rw, req)
} else {
u.IndexFile().ServeHTTP(rw, req)
}
})
}
func (u *Handler) IndexFile() http.Handler {
return u.indexMiddleware(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if path, isURL := u.path(); isURL {
_ = serveIndex(rw, path)
} else {
http.ServeFile(rw, req, filepath.Join(path, "index.html"))
}
}))
}

View File

@ -1,43 +1,30 @@
package ui
import (
"crypto/tls"
"io"
"net/http"
"sync"
"github.com/cnrancher/kube-explorer/internal/ui/content"
"github.com/rancher/apiserver/pkg/middleware"
"github.com/sirupsen/logrus"
)
const (
defaultPath = "./ui"
)
var (
insecureClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
)
type StringSetting func() string
type BoolSetting func() bool
func StaticSetting[T any](input T) func() T {
return func() T {
return input
}
}
type Handler struct {
contentHandlers map[string]content.Handler
pathSetting func() string
indexSetting func() string
releaseSetting func() bool
offlineSetting func() string
middleware func(http.Handler) http.Handler
indexMiddleware func(http.Handler) http.Handler
downloadOnce sync.Once
downloadSuccess bool
}
type Options struct {
@ -45,7 +32,7 @@ type Options struct {
Path StringSetting
// The HTTP URL of the index file to download
Index StringSetting
// Whether or not to run the UI offline, should return true/false/dynamic
// Whether or not to run the UI offline, should return true/false/dynamic/embed
Offline StringSetting
// Whether or not is it release, if true UI will run offline if set to dynamic
ReleaseSetting BoolSetting
@ -57,10 +44,11 @@ func NewUIHandler(opts *Options) *Handler {
}
h := &Handler{
indexSetting: opts.Index,
offlineSetting: opts.Offline,
pathSetting: opts.Path,
releaseSetting: opts.ReleaseSetting,
contentHandlers: make(map[string]content.Handler),
indexSetting: opts.Index,
offlineSetting: opts.Offline,
pathSetting: opts.Path,
releaseSetting: opts.ReleaseSetting,
middleware: middleware.Chain{
middleware.Gzip,
middleware.FrameOptions,
@ -75,67 +63,76 @@ func NewUIHandler(opts *Options) *Handler {
}
if h.indexSetting == nil {
h.indexSetting = func() string {
return "https://releases.rancher.com/dashboard/latest/index.html"
}
h.indexSetting = StaticSetting("")
}
if h.offlineSetting == nil {
h.offlineSetting = func() string {
return "dynamic"
}
h.offlineSetting = StaticSetting("dynamic")
}
if h.pathSetting == nil {
h.pathSetting = func() string {
return defaultPath
}
h.pathSetting = StaticSetting("")
}
if h.releaseSetting == nil {
h.releaseSetting = func() bool {
return false
}
h.releaseSetting = StaticSetting(false)
}
h.contentHandlers["embed"] = content.NewEmbedded(staticContent, "ui")
h.contentHandlers["false"] = content.NewExternal(h.indexSetting)
h.contentHandlers["true"] = content.NewFilepath(h.pathSetting)
return h
}
func (u *Handler) path() (path string, isURL bool) {
switch u.offlineSetting() {
case "dynamic":
if u.releaseSetting() {
return u.pathSetting(), false
func (h *Handler) content() content.Handler {
offline := h.offlineSetting()
if handler, ok := h.contentHandlers[offline]; ok {
return handler
}
embedHandler := h.contentHandlers["embed"]
filepathHandler := h.contentHandlers["true"]
externalHandler := h.contentHandlers["false"]
// default to dynamic
switch {
case h.pathSetting() != "":
if _, err := filepathHandler.GetIndex(); err == nil {
return filepathHandler
}
if u.canDownload(u.indexSetting()) {
return u.indexSetting(), true
}
return u.pathSetting(), false
case "true":
return u.pathSetting(), false
fallthrough
case h.releaseSetting():
// release must use embed first
return embedHandler
default:
return u.indexSetting(), true
}
}
func (u *Handler) canDownload(url string) bool {
u.downloadOnce.Do(func() {
if err := serveIndex(io.Discard, url); err == nil {
u.downloadSuccess = true
} else {
logrus.Errorf("Failed to download %s, falling back to packaged UI", url)
// try embed
if _, err := embedHandler.GetIndex(); err == nil {
return embedHandler
}
})
return u.downloadSuccess
}
func serveIndex(resp io.Writer, url string) error {
r, err := insecureClient.Get(url)
if err != nil {
return err
return externalHandler
}
defer r.Body.Close()
_, err = io.Copy(resp, r.Body)
return err
}
func (h *Handler) ServeAssets(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.content().ServeAssets(h.middleware, next).ServeHTTP(w, r)
})
}
func (h *Handler) ServeFaviconDashboard() http.Handler {
return h.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.content().ServeFaviconDashboard().ServeHTTP(w, r)
}))
}
func (h *Handler) IndexFile() http.Handler {
return h.indexMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rtn, err := h.content().GetIndex()
if err != nil {
logrus.Warnf("failed to serve index with error %v", err)
http.NotFoundHandler().ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rtn)
}))
}

View File

@ -4,29 +4,11 @@ import (
"net/http"
"strings"
"github.com/cnrancher/kube-explorer/internal/version"
"github.com/gorilla/mux"
)
func New(path string) http.Handler {
vue := NewUIHandler(&Options{
Path: func() string {
if path == "" {
return defaultPath
}
return path
},
Offline: func() string {
if path != "" {
return "true"
}
return "dynamic"
},
ReleaseSetting: func() bool {
return version.IsRelease()
},
})
func New(opt *Options) (http.Handler, APIUI) {
vue := NewUIHandler(opt)
router := mux.NewRouter()
router.UseEncodedPath()
@ -35,7 +17,8 @@ func New(path string) http.Handler {
router.Handle("/dashboard/", vue.IndexFile())
router.Handle("/favicon.png", vue.ServeFaviconDashboard())
router.Handle("/favicon.ico", vue.ServeFaviconDashboard())
router.PathPrefix("/dashboard/").Handler(vue.IndexFileOnNotFound())
router.PathPrefix("/dashboard/").Handler(vue.ServeAssets(vue.IndexFile()))
router.PathPrefix("/api-ui/").Handler(vue.ServeAssets(http.NotFoundHandler()))
router.PathPrefix("/k8s/clusters/local").HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
url := strings.TrimPrefix(req.URL.Path, "/k8s/clusters/local")
if url == "" {
@ -44,5 +27,5 @@ func New(path string) http.Handler {
http.Redirect(rw, req, url, http.StatusFound)
})
return router
return router, apiUI(opt)
}

15
main.go
View File

@ -14,19 +14,14 @@ import (
"github.com/cnrancher/kube-explorer/internal/server"
)
var (
config stevecli.Config
debugconfig debug.Config
)
func main() {
app := cli.NewApp()
app.Name = "kube-explorer"
app.Version = version.FriendlyVersion()
app.Usage = ""
app.Flags = joinFlags(
stevecli.Flags(&config),
debug.Flags(&debugconfig),
stevecli.Flags(&keconfig.Steve),
debug.Flags(&keconfig.Debug),
keconfig.Flags(),
)
app.Action = run
@ -38,12 +33,12 @@ func main() {
func run(_ *cli.Context) error {
ctx := signals.SetupSignalContext()
debugconfig.MustSetupDebug()
s, err := server.ToServer(ctx, &config, false)
keconfig.Debug.MustSetupDebug()
s, err := server.ToServer(ctx, &keconfig.Steve, false)
if err != nil {
return err
}
return s.ListenAndServe(ctx, config.HTTPSListenPort, config.HTTPListenPort, nil)
return s.ListenAndServe(ctx, keconfig.Steve.HTTPSListenPort, keconfig.Steve.HTTPListenPort, nil)
}
func joinFlags(flags ...[]cli.Flag) []cli.Flag {

View File

@ -11,7 +11,8 @@ OS_ARCH_ARG_DARWIN="amd64 arm64"
OS_ARCH_ARG_WINDOWS="amd64"
LD_INJECT_VALUES="-X github.com/cnrancher/kube-explorer/internal/version.Version=$VERSION
-X github.com/cnrancher/kube-explorer/internal/version.GitCommit=$COMMIT"
-X github.com/cnrancher/kube-explorer/internal/version.GitCommit=$COMMIT
-X github.com/cnrancher/kube-explorer/internal/config.APIUIVersion=$CATTLE_API_UI_VERSION"
[ "$(uname)" != "Darwin" ] && LINKFLAGS="-extldflags -static -s"

View File

@ -10,9 +10,13 @@ else
TAR_CMD="tar"
fi
rm -rf internal/ui/ui/*
mkdir -p internal/ui/ui/dashboard
cd internal/ui/ui/dashboard || exit 1;
curl -sL https://pandaria-dashboard-ui.s3.ap-southeast-2.amazonaws.com/release-2.8-cn/kube-explorer-ui/${CATTLE_DASHBOARD_UI_VERSION}.tar.gz | $TAR_CMD xvzf - --strip-components=2
cp index.html ../index.html
mkdir ../api-ui
cd ../api-ui || exit 1;
curl -sL https://releases.rancher.com/api-ui/${CATTLE_API_UI_VERSION}.tar.gz | $TAR_CMD xvzf - --strip-components=1