service: tighten metis access and recovery ui
This commit is contained in:
parent
a103a654f7
commit
791d528a99
@ -9,50 +9,50 @@ import (
|
|||||||
|
|
||||||
// Inventory is the root document defining node classes and per-node specs.
|
// Inventory is the root document defining node classes and per-node specs.
|
||||||
type Inventory struct {
|
type Inventory struct {
|
||||||
Classes []NodeClass `yaml:"classes"`
|
Classes []NodeClass `yaml:"classes" json:"classes"`
|
||||||
Nodes []NodeSpec `yaml:"nodes"`
|
Nodes []NodeSpec `yaml:"nodes" json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeClass defines a reusable image/config for a group of nodes.
|
// NodeClass defines a reusable image/config for a group of nodes.
|
||||||
type NodeClass struct {
|
type NodeClass struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Arch string `yaml:"arch"`
|
Arch string `yaml:"arch" json:"arch"`
|
||||||
OS string `yaml:"os"`
|
OS string `yaml:"os" json:"os"`
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image" json:"image"`
|
||||||
Checksum string `yaml:"checksum,omitempty"`
|
Checksum string `yaml:"checksum,omitempty" json:"checksum,omitempty"`
|
||||||
K3sVersion string `yaml:"k3s_version,omitempty"`
|
K3sVersion string `yaml:"k3s_version,omitempty" json:"k3s_version,omitempty"`
|
||||||
BootloaderNote string `yaml:"bootloader_note,omitempty"`
|
BootloaderNote string `yaml:"bootloader_note,omitempty" json:"bootloader_note,omitempty"`
|
||||||
DefaultLabels map[string]string `yaml:"default_labels,omitempty"`
|
DefaultLabels map[string]string `yaml:"default_labels,omitempty" json:"default_labels,omitempty"`
|
||||||
DefaultTaints []string `yaml:"default_taints,omitempty"`
|
DefaultTaints []string `yaml:"default_taints,omitempty" json:"default_taints,omitempty"`
|
||||||
CloudInit string `yaml:"cloud_init,omitempty"`
|
CloudInit string `yaml:"cloud_init,omitempty" json:"cloud_init,omitempty"`
|
||||||
BootOverlay string `yaml:"boot_overlay,omitempty"` // path to overlay files for boot partition
|
BootOverlay string `yaml:"boot_overlay,omitempty" json:"boot_overlay,omitempty"` // path to overlay files for boot partition
|
||||||
RootOverlay string `yaml:"root_overlay,omitempty"` // path to overlay files for rootfs
|
RootOverlay string `yaml:"root_overlay,omitempty" json:"root_overlay,omitempty"` // path to overlay files for rootfs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeSpec captures per-node overrides and identity.
|
// NodeSpec captures per-node overrides and identity.
|
||||||
type NodeSpec struct {
|
type NodeSpec struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Class string `yaml:"class"`
|
Class string `yaml:"class" json:"class"`
|
||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname" json:"hostname"`
|
||||||
IP string `yaml:"ip"`
|
IP string `yaml:"ip" json:"ip"`
|
||||||
MAC string `yaml:"mac,omitempty"`
|
MAC string `yaml:"mac,omitempty" json:"mac,omitempty"`
|
||||||
K3sRole string `yaml:"k3s_role"`
|
K3sRole string `yaml:"k3s_role" json:"k3s_role"`
|
||||||
K3sVersion string `yaml:"k3s_version,omitempty"`
|
K3sVersion string `yaml:"k3s_version,omitempty" json:"k3s_version,omitempty"`
|
||||||
K3sToken string `yaml:"k3s_token,omitempty"`
|
K3sToken string `yaml:"k3s_token,omitempty" json:"k3s_token,omitempty"`
|
||||||
K3sURL string `yaml:"k3s_url,omitempty"`
|
K3sURL string `yaml:"k3s_url,omitempty" json:"k3s_url,omitempty"`
|
||||||
Labels map[string]string `yaml:"labels,omitempty"`
|
Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
|
||||||
Taints []string `yaml:"taints,omitempty"`
|
Taints []string `yaml:"taints,omitempty" json:"taints,omitempty"`
|
||||||
LonghornDisks []LonghornDisk `yaml:"longhorn_disks,omitempty"`
|
LonghornDisks []LonghornDisk `yaml:"longhorn_disks,omitempty" json:"longhorn_disks,omitempty"`
|
||||||
SSHUser string `yaml:"ssh_user,omitempty"`
|
SSHUser string `yaml:"ssh_user,omitempty" json:"ssh_user,omitempty"`
|
||||||
SSHAuthorized []string `yaml:"ssh_authorized_keys,omitempty"`
|
SSHAuthorized []string `yaml:"ssh_authorized_keys,omitempty" json:"ssh_authorized_keys,omitempty"`
|
||||||
Notes string `yaml:"notes,omitempty"`
|
Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LonghornDisk describes an attached disk to mount for Longhorn.
|
// LonghornDisk describes an attached disk to mount for Longhorn.
|
||||||
type LonghornDisk struct {
|
type LonghornDisk struct {
|
||||||
Mountpoint string `yaml:"mountpoint"`
|
Mountpoint string `yaml:"mountpoint" json:"mountpoint"`
|
||||||
UUID string `yaml:"uuid"`
|
UUID string `yaml:"uuid" json:"uuid"`
|
||||||
FS string `yaml:"fs,omitempty"`
|
FS string `yaml:"fs,omitempty" json:"fs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses an inventory file.
|
// Load reads and parses an inventory file.
|
||||||
|
|||||||
@ -86,10 +86,13 @@ type SnapshotRecord struct {
|
|||||||
type PageState struct {
|
type PageState struct {
|
||||||
LocalHost string `json:"local_host"`
|
LocalHost string `json:"local_host"`
|
||||||
DefaultFlashHost string `json:"default_flash_host"`
|
DefaultFlashHost string `json:"default_flash_host"`
|
||||||
|
SelectedHost string `json:"selected_host"`
|
||||||
FlashHosts []string `json:"flash_hosts"`
|
FlashHosts []string `json:"flash_hosts"`
|
||||||
Nodes []inventory.NodeSpec `json:"nodes"`
|
Nodes []inventory.NodeSpec `json:"nodes"`
|
||||||
Jobs []*Job `json:"jobs"`
|
Jobs []*Job `json:"jobs"`
|
||||||
Devices []Device `json:"devices"`
|
Devices []Device `json:"devices"`
|
||||||
|
PreferredDevice string `json:"preferred_device,omitempty"`
|
||||||
|
DeviceError string `json:"device_error,omitempty"`
|
||||||
Events []Event `json:"events"`
|
Events []Event `json:"events"`
|
||||||
Snapshots []SnapshotRecord `json:"snapshots"`
|
Snapshots []SnapshotRecord `json:"snapshots"`
|
||||||
Targets map[string]facts.Targets `json:"targets"`
|
Targets map[string]facts.Targets `json:"targets"`
|
||||||
@ -145,6 +148,9 @@ func NewApp(settings Settings) (*App, error) {
|
|||||||
|
|
||||||
// State returns the current UI/API snapshot.
|
// State returns the current UI/API snapshot.
|
||||||
func (a *App) State(deviceHost string) PageState {
|
func (a *App) State(deviceHost string) PageState {
|
||||||
|
if strings.TrimSpace(deviceHost) == "" {
|
||||||
|
deviceHost = a.settings.DefaultFlashHost
|
||||||
|
}
|
||||||
a.mu.RLock()
|
a.mu.RLock()
|
||||||
jobs := make([]*Job, 0, len(a.jobs))
|
jobs := make([]*Job, 0, len(a.jobs))
|
||||||
for _, job := range a.jobs {
|
for _, job := range a.jobs {
|
||||||
@ -169,14 +175,19 @@ func (a *App) State(deviceHost string) PageState {
|
|||||||
return snaps[i].Node < snaps[j].Node
|
return snaps[i].Node < snaps[j].Node
|
||||||
})
|
})
|
||||||
|
|
||||||
devices, _ := a.ListDevices(deviceHost)
|
flashHosts := a.flashHosts()
|
||||||
|
devices, deviceErr := a.ListDevices(deviceHost)
|
||||||
|
preferredDevice := preferredDevice(devices)
|
||||||
return PageState{
|
return PageState{
|
||||||
LocalHost: a.settings.LocalHost,
|
LocalHost: a.settings.LocalHost,
|
||||||
DefaultFlashHost: a.settings.DefaultFlashHost,
|
DefaultFlashHost: a.settings.DefaultFlashHost,
|
||||||
FlashHosts: append([]string{}, a.settings.FlashHosts...),
|
SelectedHost: deviceHost,
|
||||||
|
FlashHosts: flashHosts,
|
||||||
Nodes: append([]inventory.NodeSpec{}, a.inventory.Nodes...),
|
Nodes: append([]inventory.NodeSpec{}, a.inventory.Nodes...),
|
||||||
Jobs: jobs,
|
Jobs: jobs,
|
||||||
Devices: devices,
|
Devices: devices,
|
||||||
|
PreferredDevice: preferredDevice,
|
||||||
|
DeviceError: errorString(deviceErr),
|
||||||
Events: a.recentEvents(40),
|
Events: a.recentEvents(40),
|
||||||
Snapshots: snaps,
|
Snapshots: snaps,
|
||||||
Targets: aTargets,
|
Targets: aTargets,
|
||||||
@ -199,13 +210,10 @@ func (a *App) Replace(node, host, device string) (*Job, error) {
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
host = a.settings.DefaultFlashHost
|
host = a.settings.DefaultFlashHost
|
||||||
}
|
}
|
||||||
if host != a.settings.LocalHost && host != a.settings.DefaultFlashHost {
|
|
||||||
return nil, fmt.Errorf("flash host %s is not available on this Metis instance", host)
|
|
||||||
}
|
|
||||||
if _, _, err := a.inventory.FindNode(node); err != nil {
|
if _, _, err := a.inventory.FindNode(node); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := a.ensureDevice(device); err != nil {
|
if _, err := a.ensureDevice(host, device); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
job := a.newJob("replace", node, host, device)
|
job := a.newJob("replace", node, host, device)
|
||||||
@ -299,8 +307,8 @@ func (a *App) ListDevices(host string) ([]Device, error) {
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
host = a.settings.DefaultFlashHost
|
host = a.settings.DefaultFlashHost
|
||||||
}
|
}
|
||||||
if host != a.settings.LocalHost && host != a.settings.DefaultFlashHost {
|
if !a.supportsLocalMedia(host) {
|
||||||
return nil, fmt.Errorf("flash host %s is not attached to this Metis instance", host)
|
return nil, fmt.Errorf("flash host %s is listed for planning, but this Metis instance only has direct removable-media access on %s", host, a.settings.LocalHost)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,RM,HOTPLUG,SIZE,MODEL,TRAN,TYPE")
|
cmd := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,RM,HOTPLUG,SIZE,MODEL,TRAN,TYPE")
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
@ -351,7 +359,17 @@ func (a *App) ListDevices(host string) ([]Device, error) {
|
|||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(devices, func(i, j int) bool { return devices[i].Path < devices[j].Path })
|
sort.Slice(devices, func(i, j int) bool {
|
||||||
|
left := deviceScore(devices[i])
|
||||||
|
right := deviceScore(devices[j])
|
||||||
|
if left != right {
|
||||||
|
return left > right
|
||||||
|
}
|
||||||
|
if devices[i].SizeBytes != devices[j].SizeBytes {
|
||||||
|
return devices[i].SizeBytes < devices[j].SizeBytes
|
||||||
|
}
|
||||||
|
return devices[i].Path < devices[j].Path
|
||||||
|
})
|
||||||
return devices, nil
|
return devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,7 +461,7 @@ func (a *App) runBuild(job *Job, flash bool) {
|
|||||||
j.ProgressPct = 78
|
j.ProgressPct = 78
|
||||||
j.Artifact = output
|
j.Artifact = output
|
||||||
})
|
})
|
||||||
if _, err := a.ensureDevice(job.Device); err != nil {
|
if _, err := a.ensureDevice(job.Host, job.Device); err != nil {
|
||||||
a.failJob(job.ID, err)
|
a.failJob(job.ID, err)
|
||||||
a.metrics.RecordFlash(job.Node, job.Host, "error")
|
a.metrics.RecordFlash(job.Node, job.Host, "error")
|
||||||
return
|
return
|
||||||
@ -502,8 +520,11 @@ func (a *App) flashArtifact(jobID, artifact string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureDevice(path string) (*Device, error) {
|
func (a *App) ensureDevice(host, path string) (*Device, error) {
|
||||||
devices, err := a.ListDevices(a.settings.DefaultFlashHost)
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return nil, fmt.Errorf("select removable media before starting a flash run")
|
||||||
|
}
|
||||||
|
devices, err := a.ListDevices(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -517,14 +538,14 @@ func (a *App) ensureDevice(path string) (*Device, error) {
|
|||||||
|
|
||||||
func (a *App) newJob(kind, node, host, device string) *Job {
|
func (a *App) newJob(kind, node, host, device string) *Job {
|
||||||
job := &Job{
|
job := &Job{
|
||||||
ID: fmt.Sprintf("%d", time.Now().UTC().UnixNano()),
|
ID: fmt.Sprintf("%d", time.Now().UTC().UnixNano()),
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Node: node,
|
Node: node,
|
||||||
Host: host,
|
Host: host,
|
||||||
Device: device,
|
Device: device,
|
||||||
Status: JobQueued,
|
Status: JobQueued,
|
||||||
ProgressPct: 0,
|
ProgressPct: 0,
|
||||||
StartedAt: time.Now().UTC(),
|
StartedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.jobs[job.ID] = job
|
a.jobs[job.ID] = job
|
||||||
@ -628,6 +649,32 @@ func (a *App) artifactPath(node string) string {
|
|||||||
return filepath.Join(a.settings.ArtifactDir, fmt.Sprintf("%s.img", node))
|
return filepath.Join(a.settings.ArtifactDir, fmt.Sprintf("%s.img", node))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) flashHosts() []string {
|
||||||
|
hosts := map[string]struct{}{}
|
||||||
|
for _, host := range a.settings.FlashHosts {
|
||||||
|
if value := strings.TrimSpace(host); value != "" {
|
||||||
|
hosts[value] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, host := range []string{a.settings.DefaultFlashHost, a.settings.LocalHost} {
|
||||||
|
if value := strings.TrimSpace(host); value != "" {
|
||||||
|
hosts[value] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, host := range clusterNodeNames() {
|
||||||
|
hosts[host] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(hosts))
|
||||||
|
for host := range hosts {
|
||||||
|
out = append(out, host)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if a.settings.DefaultFlashHost == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return moveToFront(out, a.settings.DefaultFlashHost)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) loadSnapshots() error {
|
func (a *App) loadSnapshots() error {
|
||||||
data, err := os.ReadFile(a.settings.SnapshotsPath)
|
data, err := os.ReadFile(a.settings.SnapshotsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -742,6 +789,67 @@ func firstLine(value string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredDevice(devices []Device) string {
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return devices[0].Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) supportsLocalMedia(host string) bool {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
return host == "" || host == a.settings.LocalHost || host == a.settings.DefaultFlashHost
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceScore(device Device) int {
|
||||||
|
score := 0
|
||||||
|
model := strings.ToLower(strings.TrimSpace(device.Model))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(model, "microsd"), strings.Contains(model, "micro sd"):
|
||||||
|
score += 60
|
||||||
|
case strings.Contains(model, "sdxc"), strings.Contains(model, "sdhc"), strings.Contains(model, "sd "):
|
||||||
|
score += 50
|
||||||
|
case strings.Contains(model, "card"), strings.Contains(model, "reader"):
|
||||||
|
score += 40
|
||||||
|
}
|
||||||
|
if device.Removable {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if device.Hotplug {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if device.Transport == "usb" {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(device.Name, "mmcblk") {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveToFront(values []string, preferred string) []string {
|
||||||
|
if preferred == "" || len(values) < 2 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
out := append([]string{}, values...)
|
||||||
|
for idx, value := range out {
|
||||||
|
if value != preferred {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copy(out[1:idx+1], out[:idx])
|
||||||
|
out[0] = preferred
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func deleteNodeObject(node string) error {
|
func deleteNodeObject(node string) error {
|
||||||
if err := deleteNodeObjectInCluster(node); err == nil {
|
if err := deleteNodeObjectInCluster(node); err == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -793,3 +901,60 @@ func deleteNodeObjectInCluster(node string) error {
|
|||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return fmt.Errorf("delete node %s failed: %s: %s", node, resp.Status, strings.TrimSpace(string(body)))
|
return fmt.Errorf("delete node %s failed: %s: %s", node, resp.Status, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clusterNodeNames() []string {
|
||||||
|
host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST"))
|
||||||
|
port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT"))
|
||||||
|
if host == "" || port == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
caPEM, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caPEM) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{RootCAs: pool},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s:%s/api/v1/nodes", host, port), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []struct {
|
||||||
|
Metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(payload.Items))
|
||||||
|
for _, item := range payload.Items {
|
||||||
|
if name := strings.TrimSpace(item.Metadata.Name); name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@ -161,15 +161,9 @@ func (a *App) withUIAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
|
|
||||||
func (a *App) authorize(r *http.Request) (userContext, bool) {
|
func (a *App) authorize(r *http.Request) (userContext, bool) {
|
||||||
user := firstNonEmptyHeader(r, "X-Auth-Request-User", "X-Forwarded-User", "X-Auth-Request-Email", "X-Forwarded-Email")
|
user := firstNonEmptyHeader(r, "X-Auth-Request-User", "X-Forwarded-User", "X-Auth-Request-Email", "X-Forwarded-Email")
|
||||||
if user == "" {
|
|
||||||
return userContext{}, false
|
|
||||||
}
|
|
||||||
groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups"))
|
groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups"))
|
||||||
normalizedUser := normalizeUserValue(user)
|
if len(groups) == 0 {
|
||||||
for _, allowedUser := range a.settings.AllowedUsers {
|
return userContext{Name: user, Groups: groups}, false
|
||||||
if normalizeUserValue(allowedUser) == normalizedUser {
|
|
||||||
return userContext{Name: user, Groups: groups}, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
for _, allowed := range a.settings.AllowedGroups {
|
for _, allowed := range a.settings.AllowedGroups {
|
||||||
@ -205,10 +199,6 @@ func splitHeaderList(raw string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeUserValue(raw string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeGroupValue(raw string) string {
|
func normalizeGroupValue(raw string) string {
|
||||||
value := strings.ToLower(strings.TrimSpace(raw))
|
value := strings.ToLower(strings.TrimSpace(raw))
|
||||||
return strings.TrimPrefix(value, "/")
|
return strings.TrimPrefix(value, "/")
|
||||||
@ -243,17 +233,21 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
<title>Metis Control</title>
|
<title>Metis Control</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--ink:#111318;
|
--bg:#081018;
|
||||||
--muted:#616778;
|
--bg-soft:#0e1722;
|
||||||
--line:rgba(17,19,24,.12);
|
--panel:#101c29;
|
||||||
--paper:rgba(255,255,255,.84);
|
--panel-strong:#172535;
|
||||||
--paper-strong:#ffffff;
|
--line:rgba(149,177,205,.18);
|
||||||
--brand:#1d5f8c;
|
--line-strong:rgba(149,177,205,.28);
|
||||||
--brand-deep:#153b59;
|
--ink:#f3f7fb;
|
||||||
--accent:#d47b37;
|
--muted:#9bb0c4;
|
||||||
--success:#1b8f5a;
|
--brand:#3da7ff;
|
||||||
--danger:#a63c35;
|
--brand-deep:#1c6ca8;
|
||||||
--shadow:0 20px 60px rgba(17,19,24,.12);
|
--accent:#ff9a4a;
|
||||||
|
--success:#3dd08c;
|
||||||
|
--danger:#ff6f6f;
|
||||||
|
--warn:#f2c14c;
|
||||||
|
--shadow:0 24px 60px rgba(0,0,0,.35);
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{
|
body{
|
||||||
@ -262,12 +256,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
|
font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
|
||||||
color:var(--ink);
|
color:var(--ink);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(212,123,55,.18), transparent 30rem),
|
radial-gradient(circle at top left, rgba(61,167,255,.20), transparent 28rem),
|
||||||
radial-gradient(circle at top right, rgba(29,95,140,.18), transparent 32rem),
|
radial-gradient(circle at top right, rgba(255,154,74,.16), transparent 26rem),
|
||||||
linear-gradient(180deg, #f8f4ee 0%, #eef2f5 48%, #e4edf2 100%);
|
linear-gradient(180deg, #071018 0%, #0a131d 50%, #0b1622 100%);
|
||||||
}
|
}
|
||||||
.frame{
|
.frame{
|
||||||
max-width:1280px;
|
max-width:1320px;
|
||||||
margin:0 auto;
|
margin:0 auto;
|
||||||
padding:2rem 1.25rem 3rem;
|
padding:2rem 1.25rem 3rem;
|
||||||
}
|
}
|
||||||
@ -276,15 +270,15 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
align-items:flex-end;
|
align-items:flex-end;
|
||||||
gap:1.5rem;
|
gap:1.5rem;
|
||||||
margin-bottom:1.5rem;
|
margin-bottom:1rem;
|
||||||
}
|
}
|
||||||
.eyebrow{
|
.eyebrow{
|
||||||
letter-spacing:.14em;
|
letter-spacing:.14em;
|
||||||
text-transform:uppercase;
|
text-transform:uppercase;
|
||||||
font-size:.72rem;
|
font-size:.72rem;
|
||||||
color:var(--brand-deep);
|
color:#81c6ff;
|
||||||
margin-bottom:.35rem;
|
margin-bottom:.35rem;
|
||||||
font-weight:700;
|
font-weight:800;
|
||||||
}
|
}
|
||||||
h1{
|
h1{
|
||||||
margin:0;
|
margin:0;
|
||||||
@ -292,7 +286,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
line-height:1;
|
line-height:1;
|
||||||
}
|
}
|
||||||
.sub{
|
.sub{
|
||||||
max-width:54rem;
|
max-width:56rem;
|
||||||
color:var(--muted);
|
color:var(--muted);
|
||||||
margin-top:.7rem;
|
margin-top:.7rem;
|
||||||
font-size:1rem;
|
font-size:1rem;
|
||||||
@ -301,27 +295,40 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
display:inline-flex;
|
display:inline-flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
gap:.45rem;
|
gap:.45rem;
|
||||||
padding:.7rem .95rem;
|
padding:.78rem 1rem;
|
||||||
background:rgba(255,255,255,.72);
|
background:rgba(16,28,41,.82);
|
||||||
border:1px solid rgba(21,59,89,.12);
|
border:1px solid var(--line);
|
||||||
border-radius:999px;
|
border-radius:999px;
|
||||||
box-shadow:var(--shadow);
|
box-shadow:var(--shadow);
|
||||||
font-size:.9rem;
|
font-size:.92rem;
|
||||||
}
|
}
|
||||||
|
.banner{
|
||||||
|
display:flex;
|
||||||
|
align-items:flex-start;
|
||||||
|
gap:.75rem;
|
||||||
|
padding:1rem 1.1rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
border:1px solid var(--line);
|
||||||
|
background:rgba(16,28,41,.84);
|
||||||
|
margin-bottom:1rem;
|
||||||
|
box-shadow:var(--shadow);
|
||||||
|
}
|
||||||
|
.banner.hidden{display:none}
|
||||||
|
.banner.info{border-color:rgba(61,167,255,.32);background:rgba(17,36,54,.88)}
|
||||||
|
.banner.success{border-color:rgba(61,208,140,.32);background:rgba(10,41,31,.9)}
|
||||||
|
.banner.error{border-color:rgba(255,111,111,.32);background:rgba(56,18,18,.9)}
|
||||||
|
.banner.warn{border-color:rgba(242,193,76,.32);background:rgba(53,40,13,.9)}
|
||||||
|
.banner strong{display:block;margin-bottom:.15rem}
|
||||||
.grid{
|
.grid{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:1.2fr .9fr;
|
grid-template-columns:1.16fr .9fr;
|
||||||
gap:1rem;
|
|
||||||
}
|
|
||||||
.stack{
|
|
||||||
display:grid;
|
|
||||||
gap:1rem;
|
gap:1rem;
|
||||||
}
|
}
|
||||||
|
.stack{display:grid;gap:1rem}
|
||||||
.card{
|
.card{
|
||||||
background:var(--paper);
|
background:linear-gradient(180deg, rgba(16,28,41,.95), rgba(12,21,31,.94));
|
||||||
backdrop-filter:blur(14px);
|
|
||||||
border:1px solid var(--line);
|
border:1px solid var(--line);
|
||||||
border-radius:1.25rem;
|
border-radius:1.35rem;
|
||||||
padding:1.1rem;
|
padding:1.1rem;
|
||||||
box-shadow:var(--shadow);
|
box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
@ -330,13 +337,19 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
font-size:1rem;
|
font-size:1rem;
|
||||||
text-transform:uppercase;
|
text-transform:uppercase;
|
||||||
letter-spacing:.1em;
|
letter-spacing:.1em;
|
||||||
color:var(--brand-deep);
|
color:#8bccff;
|
||||||
}
|
}
|
||||||
.hint{
|
.hint{
|
||||||
color:var(--muted);
|
color:var(--muted);
|
||||||
font-size:.92rem;
|
font-size:.92rem;
|
||||||
margin-bottom:1rem;
|
margin-bottom:1rem;
|
||||||
}
|
}
|
||||||
|
.microcopy{
|
||||||
|
color:var(--muted);
|
||||||
|
font-size:.84rem;
|
||||||
|
margin-top:.5rem;
|
||||||
|
min-height:1.2rem;
|
||||||
|
}
|
||||||
.form-grid{
|
.form-grid{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
@ -345,31 +358,47 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
label{
|
label{
|
||||||
display:grid;
|
display:grid;
|
||||||
gap:.35rem;
|
gap:.35rem;
|
||||||
font-weight:600;
|
font-weight:700;
|
||||||
font-size:.92rem;
|
font-size:.92rem;
|
||||||
}
|
}
|
||||||
select, button{
|
select, button{
|
||||||
width:100%;
|
width:100%;
|
||||||
border-radius:.85rem;
|
border-radius:.95rem;
|
||||||
border:1px solid rgba(17,19,24,.14);
|
border:1px solid var(--line-strong);
|
||||||
padding:.85rem .95rem;
|
padding:.9rem .95rem;
|
||||||
font:inherit;
|
font:inherit;
|
||||||
}
|
}
|
||||||
|
select{
|
||||||
|
background:rgba(11,21,33,.96);
|
||||||
|
color:var(--ink);
|
||||||
|
min-height:3.1rem;
|
||||||
|
}
|
||||||
|
select:focus, button:focus{
|
||||||
|
outline:2px solid rgba(61,167,255,.28);
|
||||||
|
outline-offset:2px;
|
||||||
|
}
|
||||||
button{
|
button{
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
|
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
|
||||||
color:#fff;
|
color:#fff;
|
||||||
border:none;
|
border:none;
|
||||||
font-weight:700;
|
font-weight:800;
|
||||||
letter-spacing:.03em;
|
letter-spacing:.03em;
|
||||||
box-shadow:0 14px 30px rgba(21,59,89,.18);
|
box-shadow:0 14px 30px rgba(17,66,102,.30);
|
||||||
|
transition:transform .12s ease, opacity .12s ease;
|
||||||
}
|
}
|
||||||
|
button:hover{transform:translateY(-1px)}
|
||||||
button.secondary{
|
button.secondary{
|
||||||
background:#fff;
|
background:rgba(18,30,43,.96);
|
||||||
color:var(--ink);
|
color:var(--ink);
|
||||||
border:1px solid rgba(17,19,24,.14);
|
border:1px solid var(--line-strong);
|
||||||
box-shadow:none;
|
box-shadow:none;
|
||||||
}
|
}
|
||||||
|
button:disabled{
|
||||||
|
opacity:.55;
|
||||||
|
cursor:not-allowed;
|
||||||
|
transform:none;
|
||||||
|
}
|
||||||
.actions{
|
.actions{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
@ -383,10 +412,10 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
overflow:auto;
|
overflow:auto;
|
||||||
}
|
}
|
||||||
.item{
|
.item{
|
||||||
border:1px solid rgba(17,19,24,.1);
|
border:1px solid rgba(149,177,205,.14);
|
||||||
border-radius:1rem;
|
border-radius:1rem;
|
||||||
padding:.85rem .95rem;
|
padding:.85rem .95rem;
|
||||||
background:rgba(255,255,255,.8);
|
background:rgba(8,17,27,.78);
|
||||||
}
|
}
|
||||||
.item-head{
|
.item-head{
|
||||||
display:flex;
|
display:flex;
|
||||||
@ -401,7 +430,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
}
|
}
|
||||||
.bar{
|
.bar{
|
||||||
height:.55rem;
|
height:.55rem;
|
||||||
background:rgba(17,19,24,.08);
|
background:rgba(149,177,205,.12);
|
||||||
border-radius:999px;
|
border-radius:999px;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
margin-top:.7rem;
|
margin-top:.7rem;
|
||||||
@ -418,12 +447,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
font-size:.75rem;
|
font-size:.75rem;
|
||||||
text-transform:uppercase;
|
text-transform:uppercase;
|
||||||
letter-spacing:.08em;
|
letter-spacing:.08em;
|
||||||
background:rgba(21,59,89,.08);
|
background:rgba(61,167,255,.12);
|
||||||
color:var(--brand-deep);
|
color:#9bd1ff;
|
||||||
}
|
}
|
||||||
.pill.done{background:rgba(27,143,90,.12);color:var(--success)}
|
.pill.done{background:rgba(61,208,140,.12);color:var(--success)}
|
||||||
.pill.error{background:rgba(166,60,53,.12);color:var(--danger)}
|
.pill.error{background:rgba(255,111,111,.12);color:var(--danger)}
|
||||||
.pill.running{background:rgba(212,123,55,.12);color:#9a5a20}
|
.pill.running{background:rgba(255,154,74,.14);color:var(--accent)}
|
||||||
.mini{
|
.mini{
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
@ -432,10 +461,16 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
.stat{
|
.stat{
|
||||||
padding:.8rem .9rem;
|
padding:.8rem .9rem;
|
||||||
border-radius:1rem;
|
border-radius:1rem;
|
||||||
background:rgba(255,255,255,.72);
|
background:rgba(8,17,27,.72);
|
||||||
border:1px solid rgba(17,19,24,.08);
|
border:1px solid rgba(149,177,205,.12);
|
||||||
}
|
}
|
||||||
.stat strong{display:block;font-size:1.35rem}
|
.stat strong{display:block;font-size:1.35rem}
|
||||||
|
.row{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:1rem;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
code{
|
code{
|
||||||
font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
|
font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
|
||||||
font-size:.88em;
|
font-size:.88em;
|
||||||
@ -443,6 +478,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
@media (max-width: 980px){
|
@media (max-width: 980px){
|
||||||
.grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
|
.grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
|
||||||
.mast{align-items:flex-start;flex-direction:column}
|
.mast{align-items:flex-start;flex-direction:column}
|
||||||
|
.row{align-items:flex-start;flex-direction:column}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -457,6 +493,13 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
<div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
|
<div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="status-banner" class="banner hidden" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<strong id="status-title">Ready</strong>
|
||||||
|
<div id="status-text">Metis is ready.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@ -473,6 +516,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
<select id="device-select"></select>
|
<select id="device-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="microcopy" id="host-note"></div>
|
||||||
|
<div class="microcopy" id="device-note"></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="secondary" id="refresh-devices">Refresh media</button>
|
<button class="secondary" id="refresh-devices">Refresh media</button>
|
||||||
<button class="secondary" id="build-only">Build image only</button>
|
<button class="secondary" id="build-only">Build image only</button>
|
||||||
@ -507,8 +552,12 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h2>Recent Changes</h2>
|
<div class="row">
|
||||||
<p class="hint">This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.</p>
|
<div>
|
||||||
|
<h2>Recent Changes</h2>
|
||||||
|
<p class="hint">This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="events" class="list"></div>
|
<div id="events" class="list"></div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@ -518,6 +567,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
<script>
|
<script>
|
||||||
const boot = JSON.parse(document.getElementById('boot').textContent);
|
const boot = JSON.parse(document.getElementById('boot').textContent);
|
||||||
let state = boot;
|
let state = boot;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
const nodeSelect = document.getElementById('node-select');
|
const nodeSelect = document.getElementById('node-select');
|
||||||
const hostSelect = document.getElementById('host-select');
|
const hostSelect = document.getElementById('host-select');
|
||||||
const deviceSelect = document.getElementById('device-select');
|
const deviceSelect = document.getElementById('device-select');
|
||||||
@ -525,120 +576,290 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
|
|||||||
const eventsEl = document.getElementById('events');
|
const eventsEl = document.getElementById('events');
|
||||||
const snapshotCountEl = document.getElementById('snapshot-count');
|
const snapshotCountEl = document.getElementById('snapshot-count');
|
||||||
const targetCountEl = document.getElementById('target-count');
|
const targetCountEl = document.getElementById('target-count');
|
||||||
|
const hostNoteEl = document.getElementById('host-note');
|
||||||
|
const deviceNoteEl = document.getElementById('device-note');
|
||||||
|
const bannerEl = document.getElementById('status-banner');
|
||||||
|
const bannerTitleEl = document.getElementById('status-title');
|
||||||
|
const bannerTextEl = document.getElementById('status-text');
|
||||||
|
const actionButtons = Array.from(document.querySelectorAll('button'));
|
||||||
|
|
||||||
function fmtTime(value){
|
function fmtTime(value){
|
||||||
if(!value){ return 'pending'; }
|
if(!value){ return 'pending'; }
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return isNaN(date.getTime()) ? value : date.toLocaleString();
|
return isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
}
|
}
|
||||||
function fmtBytes(value){
|
|
||||||
if(!value){ return '0 B'; }
|
function fmtBytes(value){
|
||||||
const units = ['B','KiB','MiB','GiB','TiB'];
|
if(!value){ return '0 B'; }
|
||||||
let size = Number(value);
|
const units = ['B','KiB','MiB','GiB','TiB'];
|
||||||
|
let size = Number(value);
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
while(size >= 1024 && idx < units.length - 1){
|
while(size >= 1024 && idx < units.length - 1){
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
|
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
|
||||||
}
|
}
|
||||||
function setOptions(select, values, labeler){
|
|
||||||
|
function banner(kind, title, text){
|
||||||
|
bannerEl.className = 'banner ' + kind;
|
||||||
|
bannerTitleEl.textContent = title;
|
||||||
|
bannerTextEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBanner(){
|
||||||
|
bannerEl.className = 'banner hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(nextBusy){
|
||||||
|
busy = nextBusy;
|
||||||
|
actionButtons.forEach((button)=>{
|
||||||
|
button.disabled = nextBusy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestDevicePath(){
|
||||||
|
return state.preferred_device || (state.devices[0] ? state.devices[0].path : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptions(select, values, labeler, emptyLabel){
|
||||||
const current = select.value;
|
const current = select.value;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
if(!values.length){
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = '';
|
||||||
|
option.textContent = emptyLabel;
|
||||||
|
select.appendChild(option);
|
||||||
|
select.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
values.forEach((value)=>{
|
values.forEach((value)=>{
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = value;
|
option.value = value;
|
||||||
option.textContent = labeler ? labeler(value) : value;
|
option.textContent = labeler ? labeler(value) : value;
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
if(current && values.includes(current)){ select.value = current; }
|
if(current && values.includes(current)){
|
||||||
|
select.value = current;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function render(){
|
|
||||||
setOptions(nodeSelect, state.nodes.map((n)=>n.name));
|
|
||||||
setOptions(hostSelect, state.flash_hosts);
|
|
||||||
if(!hostSelect.value){ hostSelect.value = state.default_flash_host; }
|
|
||||||
setOptions(deviceSelect, state.devices.map((d)=>d.path), (path)=>{
|
|
||||||
const dev = state.devices.find((item)=>item.path === path);
|
|
||||||
if(!dev){ return path; }
|
|
||||||
return dev.path + ' · ' + fmtBytes(dev.size_bytes) + ' · ' + (dev.model || dev.transport || 'removable media');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
function renderJobs(){
|
||||||
jobsEl.innerHTML = '';
|
jobsEl.innerHTML = '';
|
||||||
const jobs = state.jobs.length ? state.jobs : [{kind:'idle',status:'done',message:'No active or recent Metis jobs yet.',progress_pct:100,started_at:new Date().toISOString(),finished_at:new Date().toISOString()}];
|
const jobs = state.jobs.length ? state.jobs : [{kind:'idle',status:'done',message:'No active or recent Metis jobs yet.',progress_pct:100,started_at:new Date().toISOString(),finished_at:new Date().toISOString()}];
|
||||||
jobs.forEach((job)=>{
|
jobs.forEach((job)=>{
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'item';
|
wrap.className = 'item';
|
||||||
const statusClass = job.status === 'error' ? 'error' : (job.status === 'done' ? 'done' : (job.status === 'running' ? 'running' : ''));
|
const statusClass = job.status === 'error' ? 'error' : (job.status === 'done' ? 'done' : (job.status === 'running' ? 'running' : ''));
|
||||||
const title = job.kind.toUpperCase() + (job.node ? ' · ' + job.node : '');
|
const title = job.kind.toUpperCase() + (job.node ? ' · ' + job.node : '');
|
||||||
const started = fmtTime(job.started_at) + (job.device ? ' · ' + job.device : '') + (job.host ? ' · ' + job.host : '');
|
const started = fmtTime(job.started_at) + (job.device ? ' · ' + job.device : '') + (job.host ? ' · ' + job.host : '');
|
||||||
const progress = job.written_bytes ? (fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)) : '';
|
const detailBits = [];
|
||||||
const detail = progress + (job.artifact ? ' · ' + job.artifact : '') + (job.error ? ' · ' + job.error : '');
|
if(job.written_bytes){ detailBits.push(fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)); }
|
||||||
wrap.innerHTML =
|
if(job.artifact){ detailBits.push(job.artifact); }
|
||||||
'<div class="item-head">' +
|
if(job.error){ detailBits.push(job.error); }
|
||||||
'<span>' + title + '</span>' +
|
wrap.innerHTML =
|
||||||
'<span class="pill ' + statusClass + '">' + job.status + '</span>' +
|
'<div class="item-head">' +
|
||||||
'</div>' +
|
'<span>' + title + '</span>' +
|
||||||
'<div>' + (job.message || job.stage || 'queued') + '</div>' +
|
'<span class="pill ' + statusClass + '">' + job.status + '</span>' +
|
||||||
'<div class="meta">' + started + '</div>' +
|
'</div>' +
|
||||||
'<div class="meta">' + detail + '</div>' +
|
'<div>' + (job.message || job.stage || 'queued') + '</div>' +
|
||||||
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
|
'<div class="meta">' + started + '</div>' +
|
||||||
jobsEl.appendChild(wrap);
|
'<div class="meta">' + detailBits.join(' · ') + '</div>' +
|
||||||
});
|
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
|
||||||
|
jobsEl.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents(){
|
||||||
eventsEl.innerHTML = '';
|
eventsEl.innerHTML = '';
|
||||||
state.events.forEach((event)=>{
|
state.events.forEach((event)=>{
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'item';
|
wrap.className = 'item';
|
||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
'<div class="item-head">' +
|
'<div class="item-head">' +
|
||||||
'<span>' + event.summary + '</span>' +
|
'<span>' + event.summary + '</span>' +
|
||||||
'<span class="meta">' + fmtTime(event.time) + '</span>' +
|
'<span class="meta">' + fmtTime(event.time) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="meta"><code>' + event.kind + '</code></div>';
|
'<div class="meta"><code>' + event.kind + '</code></div>';
|
||||||
eventsEl.appendChild(wrap);
|
eventsEl.appendChild(wrap);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const nodeNames = (state.nodes || []).map((node)=>node.name).filter(Boolean);
|
||||||
|
setOptions(nodeSelect, nodeNames, null, 'No replacement nodes available');
|
||||||
|
if(!nodeSelect.value && nodeNames.length){
|
||||||
|
nodeSelect.value = nodeNames[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(hostSelect, state.flash_hosts || [], null, 'No flash hosts available');
|
||||||
|
if(state.selected_host && (state.flash_hosts || []).includes(state.selected_host)){
|
||||||
|
hostSelect.value = state.selected_host;
|
||||||
|
}
|
||||||
|
if(!hostSelect.value && state.default_flash_host){
|
||||||
|
hostSelect.value = state.default_flash_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicePaths = (state.devices || []).map((device)=>device.path);
|
||||||
|
setOptions(deviceSelect, devicePaths, (path)=>{
|
||||||
|
const dev = state.devices.find((item)=>item.path === path);
|
||||||
|
if(!dev){ return path; }
|
||||||
|
const parts = [dev.path, fmtBytes(dev.size_bytes)];
|
||||||
|
if(dev.model){ parts.push(dev.model); }
|
||||||
|
else if(dev.transport){ parts.push(dev.transport); }
|
||||||
|
return parts.join(' · ');
|
||||||
|
}, state.device_error || 'No removable media detected');
|
||||||
|
|
||||||
|
const preferredDevice = bestDevicePath();
|
||||||
|
const selectedDeviceStillExists = devicePaths.includes(deviceSelect.value);
|
||||||
|
if(preferredDevice && (!selectedDeviceStillExists || !deviceSelect.value)){
|
||||||
|
deviceSelect.value = preferredDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedHost = hostSelect.value || state.default_flash_host;
|
||||||
|
const hostIsLocal = selectedHost === state.local_host || selectedHost === state.default_flash_host;
|
||||||
|
hostNoteEl.textContent = hostIsLocal
|
||||||
|
? 'Metis is running on ' + state.local_host + ', so media detection and flashing are live for this host.'
|
||||||
|
: 'The selected host is listed from cluster inventory, but this Metis instance only has direct media access on ' + state.local_host + '.';
|
||||||
|
|
||||||
|
if(state.device_error){
|
||||||
|
deviceNoteEl.textContent = state.device_error;
|
||||||
|
} else if(state.devices.length){
|
||||||
|
deviceNoteEl.textContent = 'Best candidate preselected: ' + (bestDevicePath() || 'none');
|
||||||
|
} else {
|
||||||
|
deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('build-only').disabled = busy || !nodeSelect.value;
|
||||||
|
document.getElementById('refresh-devices').disabled = busy;
|
||||||
|
document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error;
|
||||||
|
document.getElementById('sentinel-watch').disabled = busy;
|
||||||
|
|
||||||
|
renderJobs();
|
||||||
|
renderEvents();
|
||||||
snapshotCountEl.textContent = state.snapshots.length;
|
snapshotCountEl.textContent = state.snapshots.length;
|
||||||
targetCountEl.textContent = Object.keys(state.targets || {}).length;
|
targetCountEl.textContent = Object.keys(state.targets || {}).length;
|
||||||
}
|
}
|
||||||
async function refreshState(){
|
|
||||||
const host = hostSelect.value || state.default_flash_host;
|
async function refreshState(opts = {}){
|
||||||
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
|
const host = hostSelect.value || state.default_flash_host;
|
||||||
if(resp.ok){
|
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
|
||||||
state = await resp.json();
|
if(!resp.ok){
|
||||||
render();
|
const text = await resp.text();
|
||||||
|
throw new Error(text || 'Could not refresh Metis state');
|
||||||
|
}
|
||||||
|
state = await resp.json();
|
||||||
|
render();
|
||||||
|
if(!opts.silent && state.device_error){
|
||||||
|
banner('warn', 'Flash host needs attention', state.device_error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(path, body){
|
async function post(path, body){
|
||||||
const resp = await fetch(path, {
|
const resp = await fetch(path, {
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if(!resp.ok){
|
if(!resp.ok){
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
throw new Error(text || ('Request failed for ' + path));
|
throw new Error(text || ('Request failed for ' + path));
|
||||||
}
|
}
|
||||||
return resp.json();
|
const contentType = resp.headers.get('content-type') || '';
|
||||||
|
return contentType.includes('application/json') ? resp.json() : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireValue(value, message){
|
||||||
|
if(stringsafe(value)){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
banner('error', 'Missing input', message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringsafe(value){
|
||||||
|
return !!String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(title, pending, fn){
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
banner('info', title, pending);
|
||||||
|
await fn();
|
||||||
|
} catch (error) {
|
||||||
|
banner('error', title + ' failed', error.message || String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
render();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
|
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
|
||||||
await refreshState();
|
await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{
|
||||||
|
await refreshState();
|
||||||
|
if(state.device_error){
|
||||||
|
banner('warn', 'Flash host needs attention', state.device_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner('success', 'Media refreshed', state.devices.length ? 'Detected ' + state.devices.length + ' flash candidate(s).' : 'No removable media candidates are visible yet.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('build-only').addEventListener('click', async ()=>{
|
document.getElementById('build-only').addEventListener('click', async ()=>{
|
||||||
await post('/api/jobs/build', {node: nodeSelect.value});
|
if(!requireValue(nodeSelect.value, 'Choose the target node image you want Metis to build first.')){
|
||||||
await refreshState();
|
return;
|
||||||
|
}
|
||||||
|
await runAction('Starting image build', 'Queueing the node image build now.', async ()=>{
|
||||||
|
await post('/api/jobs/build', {node: nodeSelect.value});
|
||||||
|
await refreshState({silent:true});
|
||||||
|
banner('success', 'Image build queued', 'Metis started building the replacement image for ' + nodeSelect.value + '.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('replace-run').addEventListener('click', async ()=>{
|
document.getElementById('replace-run').addEventListener('click', async ()=>{
|
||||||
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
|
if(!requireValue(nodeSelect.value, 'Choose the target node whose SD card image should be built and flashed.')){
|
||||||
await refreshState();
|
return;
|
||||||
|
}
|
||||||
|
if(!requireValue(deviceSelect.value, 'Choose removable media before starting a build-and-flash run.')){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(state.device_error){
|
||||||
|
banner('error', 'Flash host unavailable', state.device_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction('Starting build and flash', 'Queueing the full replacement workflow now.', async ()=>{
|
||||||
|
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
|
||||||
|
await refreshState({silent:true});
|
||||||
|
banner('success', 'Replacement workflow queued', 'Metis is building the image for ' + nodeSelect.value + ' and will flash ' + deviceSelect.value + '.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
|
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
|
||||||
await post('/api/sentinel/watch', {});
|
await runAction('Running sentinel watch', 'Refreshing template recommendations from the latest snapshots.', async ()=>{
|
||||||
await refreshState();
|
await post('/api/sentinel/watch', {});
|
||||||
|
await refreshState({silent:true});
|
||||||
|
banner('success', 'Sentinel watch complete', 'Metis refreshed its template recommendations.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
hostSelect.addEventListener('change', refreshState);
|
|
||||||
|
hostSelect.addEventListener('change', async ()=>{
|
||||||
|
await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{
|
||||||
|
await refreshState();
|
||||||
|
if(!state.device_error){
|
||||||
|
banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
render();
|
render();
|
||||||
setInterval(refreshState, 5000);
|
clearBanner();
|
||||||
|
setInterval(async ()=>{
|
||||||
|
try {
|
||||||
|
await refreshState({silent:true});
|
||||||
|
} catch (_error) {
|
||||||
|
// Keep the live dashboard calm during background polling.
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`))
|
</html>`))
|
||||||
|
|||||||
@ -50,18 +50,35 @@ func TestUIAuthAcceptsForwardedSlashGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIAuthAcceptsForwardedEmailForAllowedUser(t *testing.T) {
|
func TestUIAuthRejectsUserWithoutAllowedGroup(t *testing.T) {
|
||||||
app := newTestApp(t)
|
app := newTestApp(t)
|
||||||
app.settings.AllowedUsers = []string{"brad.stein@gmail.com"}
|
|
||||||
handler := app.Handler()
|
handler := app.Handler()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||||
req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com")
|
req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com")
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(resp, req)
|
handler.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected forbidden, got %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateJSONUsesLowerCaseNodeFields(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
handler := app.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||||
|
req.Header.Set("X-Auth-Request-User", "brad")
|
||||||
|
req.Header.Set("X-Auth-Request-Groups", "admin")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `"name":"titan-15"`) {
|
||||||
|
t.Fatalf("expected lowercase node name field in json, got %s", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInternalSnapshotAndWatch(t *testing.T) {
|
func TestInternalSnapshotAndWatch(t *testing.T) {
|
||||||
@ -155,7 +172,7 @@ nodes:
|
|||||||
if err := app.StoreSnapshot(SnapshotRecord{
|
if err := app.StoreSnapshot(SnapshotRecord{
|
||||||
Node: "titan-17",
|
Node: "titan-17",
|
||||||
CollectedAt: time.Now().UTC().Add(-10 * time.Minute),
|
CollectedAt: time.Now().UTC().Add(-10 * time.Minute),
|
||||||
Snapshot: sentinelSnapshot("titan-17", "6.6.63"),
|
Snapshot: sentinelSnapshot("titan-17", "6.6.63"),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("seed snapshot: %v", err)
|
t.Fatalf("seed snapshot: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ type Settings struct {
|
|||||||
DefaultFlashHost string
|
DefaultFlashHost string
|
||||||
FlashHosts []string
|
FlashHosts []string
|
||||||
LocalHost string
|
LocalHost string
|
||||||
AllowedUsers []string
|
|
||||||
AllowedGroups []string
|
AllowedGroups []string
|
||||||
MaxDeviceBytes int64
|
MaxDeviceBytes int64
|
||||||
}
|
}
|
||||||
@ -41,7 +40,6 @@ func FromEnv() Settings {
|
|||||||
DefaultFlashHost: defaultFlashHost,
|
DefaultFlashHost: defaultFlashHost,
|
||||||
FlashHosts: flashHosts,
|
FlashHosts: flashHosts,
|
||||||
LocalHost: localHost,
|
LocalHost: localHost,
|
||||||
AllowedUsers: splitList(getenvDefault("METIS_ALLOWED_USERS", "")),
|
|
||||||
AllowedGroups: splitList(getenvDefault("METIS_ALLOWED_GROUPS", "admin,maintainer")),
|
AllowedGroups: splitList(getenvDefault("METIS_ALLOWED_GROUPS", "admin,maintainer")),
|
||||||
MaxDeviceBytes: getenvInt64("METIS_MAX_DEVICE_BYTES", 300000000000),
|
MaxDeviceBytes: getenvInt64("METIS_MAX_DEVICE_BYTES", 300000000000),
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user