A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

more updates. fix web page and kubectl plugin

+916 -117
+18 -1
internal/api/proxy_client.go
··· 439 439 return 440 440 } 441 441 442 - response := ReadSecretResponse{Path: path, Data: data} 442 + // Get checksum for the secret to include in response 443 + checksum := "" 444 + if len(successfulResults) > 0 { 445 + // Get checksum from any available client (they should be consistent after consensus) 446 + for _, grpcClient := range clients { 447 + if checksumResult, checksumErr := grpcClient.GetChecksum(c.Request.Context(), path); checksumErr == nil { 448 + checksum = checksumResult 449 + break 450 + } 451 + } 452 + } 453 + 454 + response := ReadSecretResponse{ 455 + Path: path, 456 + Data: data, 457 + Checksum: checksum, 458 + DeviceCount: len(successfulResults), 459 + } 443 460 p.server.sendResponse(c, http.StatusOK, "Secret read successfully", response) 444 461 } 445 462
+4 -2
internal/api/types.go
··· 202 202 203 203 // ReadSecretResponse represents the response for reading a secret 204 204 type ReadSecretResponse struct { 205 - Path string `json:"path"` 206 - Data map[string][]byte `json:"data"` 205 + Path string `json:"path"` 206 + Data map[string][]byte `json:"data"` 207 + Checksum string `json:"checksum,omitempty"` 208 + DeviceCount int `json:"deviceCount,omitempty"` 207 209 } 208 210 209 211 // WriteSecretResponse represents the response for writing a secret
+17 -6
internal/mirror/manager.go
··· 611 611 return nil 612 612 } 613 613 614 - // getAvailableDevices gets list of available physical HSM device instances from HSMPools cluster-wide 614 + // getAvailableDevices gets list of available HSM device types from HSMPools cluster-wide 615 615 func (mm *MirrorManager) getAvailableDevices(ctx context.Context) ([]string, error) { 616 616 var hsmPoolList hsmv1alpha1.HSMPoolList 617 617 // List HSMPools cluster-wide since they exist in the same namespace as their HSMDevices ··· 619 619 return nil, fmt.Errorf("failed to list HSM pools cluster-wide: %w", err) 620 620 } 621 621 622 - var devices = []string{} 622 + deviceTypes := make(map[string]bool) 623 623 624 624 for _, pool := range hsmPoolList.Items { 625 625 if pool.Status.Phase == hsmv1alpha1.HSMPoolPhaseReady && len(pool.Status.AggregatedDevices) > 0 { 626 + // Check if any devices in this pool are available 627 + hasAvailableDevices := false 626 628 for _, aggregatedDevice := range pool.Status.AggregatedDevices { 627 629 if aggregatedDevice.Available { 628 - deviceName := &pool.OwnerReferences[0].Name 629 - // Create device instance name: deviceRef-index (e.g., "pico-hsm-0", "pico-hsm-1") 630 - deviceInstanceName := fmt.Sprintf("%s-%s", *deviceName, aggregatedDevice.SerialNumber) 631 - devices = append(devices, deviceInstanceName) 630 + hasAvailableDevices = true 631 + break 632 632 } 633 633 } 634 + 635 + if hasAvailableDevices { 636 + deviceName := pool.OwnerReferences[0].Name 637 + // Use base device name (e.g., "pico-hsm") - agent manager handles multiple instances internally 638 + deviceTypes[deviceName] = true 639 + } 634 640 } 635 641 } 636 642 643 + // Convert map to sorted slice 644 + devices := make([]string, 0, len(deviceTypes)) 645 + for deviceName := range deviceTypes { 646 + devices = append(devices, deviceName) 647 + } 637 648 sort.Strings(devices) // Ensure consistent ordering 638 649 return devices, nil 639 650 }
+2 -1
kubectl-hsm/cmd/main.go
··· 82 82 83 83 // Add operational commands 84 84 cmd.AddCommand(commands.NewHealthCmd()) 85 + cmd.AddCommand(commands.NewDevicesCmd()) 85 86 86 87 // Add completion command 87 88 cmd.AddCommand(newCompletionCmd()) ··· 178 179 }) 179 180 180 181 return cmd 181 - } 182 + }
+25 -5
kubectl-hsm/pkg/client/client.go
··· 58 58 if err == nil && existing != nil { 59 59 // Merge existing data with new data (new data takes precedence) 60 60 mergedData := make(map[string]any) 61 - 61 + 62 62 // Start with existing data 63 63 for k, v := range existing.Data { 64 64 mergedData[k] = v 65 65 } 66 - 66 + 67 67 // Override/add with new data 68 68 for k, v := range data { 69 69 mergedData[k] = v 70 70 } 71 - 71 + 72 72 data = mergedData 73 73 } 74 74 // If error reading existing secret, continue with original data (new secret) ··· 94 94 // ListSecrets lists all secrets in the HSM 95 95 func (c *Client) ListSecrets(ctx context.Context, page, pageSize int) (*SecretList, error) { 96 96 path := "/api/v1/hsm/secrets" 97 - 97 + 98 98 // Add pagination parameters if specified 99 99 if page > 0 || pageSize > 0 { 100 100 params := url.Values{} ··· 132 132 return &result, nil 133 133 } 134 134 135 + // GetDeviceStatus retrieves the connectivity status of all HSM devices 136 + func (c *Client) GetDeviceStatus(ctx context.Context) (*DeviceStatusResponse, error) { 137 + var result DeviceStatusResponse 138 + err := c.doRequest(ctx, "GET", "/api/v1/hsm/status", nil, &result) 139 + if err != nil { 140 + return nil, err 141 + } 142 + return &result, nil 143 + } 144 + 145 + // GetDeviceInfo retrieves detailed information about all HSM devices 146 + func (c *Client) GetDeviceInfo(ctx context.Context) (*DeviceInfoResponse, error) { 147 + var result DeviceInfoResponse 148 + err := c.doRequest(ctx, "GET", "/api/v1/hsm/info", nil, &result) 149 + if err != nil { 150 + return nil, err 151 + } 152 + return &result, nil 153 + } 154 + 135 155 // doRequest performs an HTTP request and handles the standard API response format 136 156 func (c *Client) doRequest(ctx context.Context, method, path string, requestBody any, responseData any) error { 137 157 url := c.baseURL + path ··· 191 211 } 192 212 193 213 return nil 194 - } 214 + }
+39 -13
kubectl-hsm/pkg/client/types.go
··· 22 22 23 23 // APIResponse represents a standard API response from HSM operator 24 24 type APIResponse struct { 25 - Success bool `json:"success"` 26 - Message string `json:"message,omitempty"` 27 - Data any `json:"data,omitempty"` 25 + Success bool `json:"success"` 26 + Message string `json:"message,omitempty"` 27 + Data any `json:"data,omitempty"` 28 28 Error *APIError `json:"error,omitempty"` 29 29 } 30 30 ··· 37 37 38 38 // SecretData represents the actual secret data 39 39 type SecretData struct { 40 - Data map[string]any `json:"data"` 41 - Metadata *SecretInfo `json:"metadata,omitempty"` 40 + Path string `json:"path,omitempty"` 41 + Data map[string]any `json:"data"` 42 + Metadata *SecretInfo `json:"metadata,omitempty"` 43 + Checksum string `json:"checksum,omitempty"` 44 + DeviceCount int `json:"deviceCount,omitempty"` 42 45 } 43 46 44 47 // SecretInfo represents information about a secret ··· 57 60 58 61 // SecretList represents a list of secrets 59 62 type SecretList struct { 60 - Secrets []string `json:"secrets,omitempty"` 61 - Paths []string `json:"paths,omitempty"` 62 - Count int `json:"count"` 63 - Total int `json:"total"` 64 - Page int `json:"page,omitempty"` 65 - PageSize int `json:"page_size,omitempty"` 66 - Prefix string `json:"prefix,omitempty"` 63 + Secrets []string `json:"secrets,omitempty"` 64 + Paths []string `json:"paths,omitempty"` 65 + Count int `json:"count"` 66 + Total int `json:"total"` 67 + Page int `json:"page,omitempty"` 68 + PageSize int `json:"page_size,omitempty"` 69 + Prefix string `json:"prefix,omitempty"` 67 70 } 68 71 69 72 // CreateSecretRequest represents a request to create a secret ··· 80 83 ReplicationEnabled bool `json:"replication_enabled"` 81 84 ActiveNodes int `json:"active_nodes"` 82 85 Timestamp time.Time `json:"timestamp"` 83 - } 86 + } 87 + 88 + // DeviceStatusResponse represents the response for device connectivity status 89 + type DeviceStatusResponse struct { 90 + Devices map[string]bool `json:"devices"` // deviceName -> connected status 91 + TotalDevices int `json:"totalDevices"` 92 + } 93 + 94 + // HSMInfo represents information about an HSM device 95 + type HSMInfo struct { 96 + Manufacturer string `json:"manufacturer"` 97 + Model string `json:"model"` 98 + SerialNumber string `json:"serialNumber"` 99 + FirmwareInfo string `json:"firmwareInfo,omitempty"` 100 + LibraryInfo string `json:"libraryInfo,omitempty"` 101 + SlotID int `json:"slotId"` 102 + TokenPresent bool `json:"tokenPresent"` 103 + TokenLabel string `json:"tokenLabel,omitempty"` 104 + } 105 + 106 + // DeviceInfoResponse represents the response for device information 107 + type DeviceInfoResponse struct { 108 + DeviceInfos map[string]*HSMInfo `json:"deviceInfos"` // deviceName -> HSMInfo 109 + }
+15 -16
kubectl-hsm/pkg/commands/common.go
··· 74 74 } 75 75 76 76 cm.portForward = pf 77 - 77 + 78 78 // Create HSM client pointing to the forwarded port 79 79 baseURL := fmt.Sprintf("http://localhost:%d", pf.GetLocalPort()) 80 80 cm.hsmClient = client.NewClient(baseURL) ··· 97 97 // readSecretValue reads a secret value, optionally hiding input for passwords 98 98 func readSecretValue(prompt string, hidden bool) (string, error) { 99 99 fmt.Print(prompt) 100 - 100 + 101 101 if hidden { 102 102 // Read password without echoing 103 103 byteValue, err := term.ReadPassword(int(syscall.Stdin)) ··· 119 119 // parseFromLiteral parses key=value pairs from --from-literal flags 120 120 func parseFromLiteral(literals []string) (map[string]any, error) { 121 121 data := make(map[string]any) 122 - 122 + 123 123 for _, literal := range literals { 124 124 parts := strings.SplitN(literal, "=", 2) 125 125 if len(parts) != 2 { ··· 127 127 } 128 128 data[parts[0]] = parts[1] 129 129 } 130 - 130 + 131 131 return data, nil 132 132 } 133 133 134 - 135 134 // promptForInteractiveInput prompts the user for secret values interactively 136 135 func promptForInteractiveInput() (map[string]any, error) { 137 136 data := make(map[string]any) 138 - 137 + 139 138 fmt.Println("Enter secret data (press Enter with empty key to finish):") 140 - 139 + 141 140 for { 142 141 key, err := readSecretValue("Key: ", false) 143 142 if err != nil { 144 143 return nil, err 145 144 } 146 - 145 + 147 146 if key == "" { 148 147 break 149 148 } 150 - 149 + 151 150 // Determine if this looks like a password field 152 151 isPassword := strings.Contains(strings.ToLower(key), "password") || 153 152 strings.Contains(strings.ToLower(key), "secret") || 154 153 strings.Contains(strings.ToLower(key), "token") || 155 154 strings.Contains(strings.ToLower(key), "key") 156 - 155 + 157 156 value, err := readSecretValue(fmt.Sprintf("Value for '%s': ", key), isPassword) 158 157 if err != nil { 159 158 return nil, err 160 159 } 161 - 160 + 162 161 data[key] = value 163 162 } 164 - 163 + 165 164 if len(data) == 0 { 166 165 return nil, fmt.Errorf("no secret data provided") 167 166 } 168 - 167 + 169 168 return data, nil 170 169 } 171 170 ··· 210 209 if key == "" { 211 210 // Only filename provided (no explicit key) 212 211 key = filepath.Base(filename) 213 - 212 + 214 213 // Remove file extension for the key 215 214 if ext := filepath.Ext(key); ext != "" { 216 215 key = strings.TrimSuffix(key, ext) ··· 247 246 // Get namespace from flag or use default 248 247 namespace, _ := cmd.Flags().GetString("namespace") 249 248 verbose, _ := cmd.Flags().GetBool("verbose") 250 - 249 + 251 250 // Create client manager 252 251 cm, err := NewClientManager(namespace, verbose) 253 252 if err != nil { ··· 269 268 } 270 269 271 270 return secretList.Secrets, cobra.ShellCompDirectiveNoFileComp 272 - } 271 + }
+2 -2
kubectl-hsm/pkg/commands/create.go
··· 185 185 } else { 186 186 fmt.Printf("Creating/updating secret '%s' in namespace '%s'...\n", secretName, cm.GetCurrentNamespace()) 187 187 } 188 - 188 + 189 189 if err := hsmClient.CreateSecretWithOptions(ctx, secretName, secretData, opts.Replace); err != nil { 190 190 return fmt.Errorf("failed to create/update secret: %w", err) 191 191 } ··· 204 204 } 205 205 206 206 return nil 207 - } 207 + }
+1 -1
kubectl-hsm/pkg/commands/delete.go
··· 136 136 } 137 137 138 138 return nil 139 - } 139 + }
+281
kubectl-hsm/pkg/commands/devices.go
··· 1 + /* 2 + Copyright 2025. 3 + 4 + Licensed under the Apache License, Version 2.0 (the "License"); 5 + you may not use this file except in compliance with the License. 6 + You may obtain a copy of the License at 7 + 8 + http://www.apache.org/licenses/LICENSE-2.0 9 + 10 + Unless required by applicable law or agreed to in writing, software 11 + distributed under the License is distributed on an "AS IS" BASIS, 12 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 + See the License for the specific language governing permissions and 14 + limitations under the License. 15 + */ 16 + 17 + package commands 18 + 19 + import ( 20 + "context" 21 + "encoding/json" 22 + "fmt" 23 + "os" 24 + "sort" 25 + "text/tabwriter" 26 + 27 + "github.com/spf13/cobra" 28 + "sigs.k8s.io/yaml" 29 + 30 + "github.com/evanjarrett/hsm-secrets-operator/kubectl-hsm/pkg/client" 31 + ) 32 + 33 + // DevicesOptions holds options for the devices command 34 + type DevicesOptions struct { 35 + CommonOptions 36 + Detailed bool 37 + } 38 + 39 + // NewDevicesCmd creates the devices command 40 + func NewDevicesCmd() *cobra.Command { 41 + opts := &DevicesOptions{} 42 + 43 + cmd := &cobra.Command{ 44 + Use: "devices [flags]", 45 + Short: "List HSM devices", 46 + Long: `List all HSM devices and their connectivity status. 47 + 48 + This command shows: 49 + - Device connectivity status (connected/disconnected) 50 + - Device names and identifiers 51 + - Basic device information (manufacturer, model, serial) 52 + - Detailed device specifications (with --detailed flag) 53 + 54 + Examples: 55 + # List all HSM devices 56 + kubectl hsm devices 57 + 58 + # Show detailed device information 59 + kubectl hsm devices --detailed 60 + 61 + # Output in JSON format 62 + kubectl hsm devices -o json`, 63 + Args: cobra.NoArgs, 64 + RunE: func(cmd *cobra.Command, args []string) error { 65 + return opts.Run(cmd.Context()) 66 + }, 67 + } 68 + 69 + cmd.Flags().BoolVar(&opts.Detailed, "detailed", false, "Show detailed device information") 70 + cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Override the default namespace") 71 + cmd.Flags().StringVarP(&opts.Output, "output", "o", "text", "Output format (text, json, yaml)") 72 + cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") 73 + 74 + // Add completion for output flag 75 + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 76 + return []string{"text", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp 77 + }) 78 + 79 + return cmd 80 + } 81 + 82 + // Run executes the devices command 83 + func (opts *DevicesOptions) Run(ctx context.Context) error { 84 + // Create client manager 85 + cm, err := NewClientManager(opts.Namespace, opts.Verbose) 86 + if err != nil { 87 + return err 88 + } 89 + defer cm.Close() 90 + 91 + // Get HSM client 92 + hsmClient, err := cm.GetClient(ctx) 93 + if err != nil { 94 + return fmt.Errorf("failed to connect to HSM operator: %w", err) 95 + } 96 + 97 + // Get device status and info in parallel 98 + statusResponse, err := hsmClient.GetDeviceStatus(ctx) 99 + if err != nil { 100 + return fmt.Errorf("failed to get device status: %w", err) 101 + } 102 + 103 + var infoResponse *client.DeviceInfoResponse 104 + if opts.Detailed || opts.Output != "text" { 105 + infoResponse, err = hsmClient.GetDeviceInfo(ctx) 106 + if err != nil { 107 + return fmt.Errorf("failed to get device info: %w", err) 108 + } 109 + } 110 + 111 + // Handle output formatting 112 + switch opts.Output { 113 + case "json": 114 + combinedOutput := map[string]interface{}{ 115 + "devices": statusResponse.Devices, 116 + "totalDevices": statusResponse.TotalDevices, 117 + } 118 + if infoResponse != nil { 119 + combinedOutput["deviceInfos"] = infoResponse.DeviceInfos 120 + } 121 + jsonBytes, err := json.MarshalIndent(combinedOutput, "", " ") 122 + if err != nil { 123 + return fmt.Errorf("failed to marshal device data to JSON: %w", err) 124 + } 125 + fmt.Println(string(jsonBytes)) 126 + case "yaml": 127 + combinedOutput := map[string]interface{}{ 128 + "devices": statusResponse.Devices, 129 + "totalDevices": statusResponse.TotalDevices, 130 + } 131 + if infoResponse != nil { 132 + combinedOutput["deviceInfos"] = infoResponse.DeviceInfos 133 + } 134 + yamlBytes, err := yaml.Marshal(combinedOutput) 135 + if err != nil { 136 + return fmt.Errorf("failed to marshal device data to YAML: %w", err) 137 + } 138 + fmt.Print(string(yamlBytes)) 139 + default: 140 + return opts.displayDevicesText(statusResponse, infoResponse, cm.GetCurrentNamespace()) 141 + } 142 + 143 + return nil 144 + } 145 + 146 + // displayDevicesText displays the devices in a human-readable format 147 + func (opts *DevicesOptions) displayDevicesText(statusResponse *client.DeviceStatusResponse, infoResponse *client.DeviceInfoResponse, namespace string) error { 148 + fmt.Printf("HSM Devices\n") 149 + fmt.Printf("===========\n\n") 150 + fmt.Printf("Namespace: %s\n", namespace) 151 + fmt.Printf("Total Devices: %d\n\n", statusResponse.TotalDevices) 152 + 153 + if statusResponse.TotalDevices == 0 { 154 + fmt.Println("No HSM devices found.") 155 + fmt.Println("\n💡 Recommendations:") 156 + fmt.Println(" • Check if HSM devices are connected and accessible") 157 + fmt.Println(" • Verify discovery pods are running: kubectl get pods -l app.kubernetes.io/component=discovery") 158 + fmt.Println(" • Check HSMPool status: kubectl get hsmpool") 159 + return nil 160 + } 161 + 162 + // Sort device names for consistent output 163 + var deviceNames []string 164 + for deviceName := range statusResponse.Devices { 165 + deviceNames = append(deviceNames, deviceName) 166 + } 167 + sort.Strings(deviceNames) 168 + 169 + if opts.Detailed { 170 + // Detailed view - show each device with full information 171 + for i, deviceName := range deviceNames { 172 + if i > 0 { 173 + fmt.Println() 174 + } 175 + opts.displayDetailedDevice(deviceName, statusResponse.Devices[deviceName], infoResponse) 176 + } 177 + } else { 178 + // Table view - compact format 179 + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 180 + fmt.Fprintln(w, "NAME\tSTATUS\tMANUFACTURER\tMODEL\tSERIAL") 181 + 182 + for _, deviceName := range deviceNames { 183 + connected := statusResponse.Devices[deviceName] 184 + statusIcon := "🔴" 185 + statusText := "Disconnected" 186 + if connected { 187 + statusIcon = "🟢" 188 + statusText = "Connected" 189 + } 190 + 191 + manufacturer := "Unknown" 192 + model := "Unknown" 193 + serial := "Unknown" 194 + 195 + if infoResponse != nil && infoResponse.DeviceInfos[deviceName] != nil { 196 + info := infoResponse.DeviceInfos[deviceName] 197 + if info.Manufacturer != "" { 198 + manufacturer = info.Manufacturer 199 + } 200 + if info.Model != "" { 201 + model = info.Model 202 + } 203 + if info.SerialNumber != "" { 204 + serial = info.SerialNumber 205 + } 206 + } 207 + 208 + fmt.Fprintf(w, "%s\t%s %s\t%s\t%s\t%s\n", 209 + deviceName, statusIcon, statusText, manufacturer, model, serial) 210 + } 211 + 212 + w.Flush() 213 + } 214 + 215 + // Summary and recommendations 216 + connectedCount := 0 217 + for _, connected := range statusResponse.Devices { 218 + if connected { 219 + connectedCount++ 220 + } 221 + } 222 + 223 + fmt.Printf("\nSummary: %d/%d devices connected\n", connectedCount, statusResponse.TotalDevices) 224 + 225 + if connectedCount == 0 { 226 + fmt.Printf("\n⚠️ All devices are disconnected!\n") 227 + fmt.Printf(" • Check HSM device connections\n") 228 + fmt.Printf(" • Verify agent pods are running: kubectl get pods -l app.kubernetes.io/name=hsm-agent\n") 229 + fmt.Printf(" • Check agent logs for connection errors\n") 230 + } else if connectedCount < statusResponse.TotalDevices { 231 + fmt.Printf("\n⚠️ Some devices are disconnected\n") 232 + fmt.Printf(" • Check disconnected device connections\n") 233 + fmt.Printf(" • Verify agent pods are healthy\n") 234 + } else if connectedCount > 1 { 235 + fmt.Printf("\n🎉 All devices connected - replication enabled!\n") 236 + } else { 237 + fmt.Printf("\n💡 Consider adding more devices for high availability\n") 238 + } 239 + 240 + return nil 241 + } 242 + 243 + // displayDetailedDevice displays detailed information for a single device 244 + func (opts *DevicesOptions) displayDetailedDevice(deviceName string, connected bool, infoResponse *client.DeviceInfoResponse) { 245 + statusIcon := "🔴" 246 + statusText := "Disconnected" 247 + if connected { 248 + statusIcon = "🟢" 249 + statusText = "Connected" 250 + } 251 + 252 + fmt.Printf("Device: %s %s %s\n", statusIcon, deviceName, statusText) 253 + fmt.Printf("Status: %s\n", statusText) 254 + 255 + if infoResponse != nil && infoResponse.DeviceInfos[deviceName] != nil { 256 + info := infoResponse.DeviceInfos[deviceName] 257 + 258 + if info.Manufacturer != "" { 259 + fmt.Printf("Manufacturer: %s\n", info.Manufacturer) 260 + } 261 + if info.Model != "" { 262 + fmt.Printf("Model: %s\n", info.Model) 263 + } 264 + if info.SerialNumber != "" { 265 + fmt.Printf("Serial Number: %s\n", info.SerialNumber) 266 + } 267 + if info.FirmwareInfo != "" { 268 + fmt.Printf("Firmware: %s\n", info.FirmwareInfo) 269 + } 270 + if info.LibraryInfo != "" { 271 + fmt.Printf("Library: %s\n", info.LibraryInfo) 272 + } 273 + fmt.Printf("Slot ID: %d\n", info.SlotID) 274 + fmt.Printf("Token Present: %t\n", info.TokenPresent) 275 + if info.TokenLabel != "" { 276 + fmt.Printf("Token Label: %s\n", info.TokenLabel) 277 + } 278 + } else { 279 + fmt.Printf("Device information: Not available\n") 280 + } 281 + }
+50 -16
kubectl-hsm/pkg/commands/get.go
··· 26 26 27 27 "github.com/spf13/cobra" 28 28 "sigs.k8s.io/yaml" 29 - 29 + 30 30 "github.com/evanjarrett/hsm-secrets-operator/kubectl-hsm/pkg/client" 31 31 ) 32 32 ··· 156 156 157 157 // displaySecretText displays the secret in a human-readable text format 158 158 func (opts *GetOptions) displaySecretText(secretName string, secretData *client.SecretData, namespace string) error { 159 - fmt.Printf("Name: %s\n", secretName) 159 + // Create device badge 160 + deviceBadge := "" 161 + if secretData.DeviceCount > 1 { 162 + deviceBadge = fmt.Sprintf(" 🔗 %d devices", secretData.DeviceCount) 163 + } else if secretData.DeviceCount == 1 { 164 + deviceBadge = " 📱 1 device" 165 + } 166 + 167 + fmt.Printf("Name: %s%s\n", secretName, deviceBadge) 160 168 fmt.Printf("Namespace: %s\n", namespace) 161 169 170 + // Display path if different from name 171 + if secretData.Path != "" && secretData.Path != secretName { 172 + fmt.Printf("Path: %s\n", secretData.Path) 173 + } 174 + 175 + // Display checksum if available 176 + if secretData.Checksum != "" { 177 + fmt.Printf("Checksum: %s\n", secretData.Checksum) 178 + } 179 + 180 + // Display device count 181 + if secretData.DeviceCount > 0 { 182 + fmt.Printf("Device Count: %d\n", secretData.DeviceCount) 183 + if secretData.DeviceCount > 1 { 184 + fmt.Printf("Replication: ✅ Enabled\n") 185 + } else { 186 + fmt.Printf("Replication: ⚠️ Single device\n") 187 + } 188 + } 162 189 163 190 // Parse metadata from _metadata key if present 164 191 if metadataValue, hasMetadata := secretData.Data["_metadata"]; hasMetadata { ··· 174 201 keys = append(keys, k) 175 202 } 176 203 } 177 - 204 + 178 205 if len(keys) > 0 { 179 206 sort.Strings(keys) 180 - fmt.Printf("Keys: %s\n", strings.Join(keys, ", ")) 207 + fmt.Printf("Keys: %s (%d total)\n", strings.Join(keys, ", "), len(keys)) 181 208 } else { 182 209 fmt.Printf("Keys: <none>\n") 210 + } 211 + 212 + // Multi-device recommendations 213 + if secretData.DeviceCount == 1 { 214 + fmt.Printf("\n💡 Recommendation: Consider adding more HSM devices for high availability\n") 215 + } else if secretData.DeviceCount > 1 { 216 + fmt.Printf("\n🎉 Secret is replicated across %d devices for high availability\n", secretData.DeviceCount) 183 217 } 184 218 185 219 return nil ··· 188 222 // parseAndDisplayMetadata parses and displays metadata from the _metadata key 189 223 func (opts *GetOptions) parseAndDisplayMetadata(metadataValue any) error { 190 224 var metadataMap map[string]any 191 - 225 + 192 226 switch v := metadataValue.(type) { 193 227 case string: 194 228 // First try to decode as base64, then parse JSON ··· 209 243 default: 210 244 return fmt.Errorf("unexpected metadata type: %T", v) 211 245 } 212 - 246 + 213 247 // Display all metadata fields with proper formatting 214 248 opts.displayMetadataFields(metadataMap, "") 215 - 249 + 216 250 return nil 217 251 } 218 252 ··· 224 258 keys = append(keys, k) 225 259 } 226 260 sort.Strings(keys) 227 - 261 + 228 262 for _, key := range keys { 229 263 value := data[key] 230 - 264 + 231 265 // Handle nested objects (like labels) 232 266 if nested, ok := value.(map[string]any); ok { 233 267 // Display the parent key with capitalization 234 268 displayKey := opts.formatMetadataKey(key) 235 269 fmt.Printf("%s%-13s\n", indent, displayKey+":") 236 - 270 + 237 271 // Display nested items with indentation, keeping original keys 238 272 nestedKeys := make([]string, 0, len(nested)) 239 273 for k := range nested { 240 274 nestedKeys = append(nestedKeys, k) 241 275 } 242 276 sort.Strings(nestedKeys) 243 - 277 + 244 278 for _, nestedKey := range nestedKeys { 245 279 fmt.Printf("%s %-11s %v\n", indent, nestedKey+":", nested[nestedKey]) 246 280 } 247 281 continue 248 282 } 249 - 283 + 250 284 // Format the display key (capitalize first letter, replace underscores with spaces) 251 285 displayKey := opts.formatMetadataKey(key) 252 - 286 + 253 287 // Display the key-value pair 254 288 fmt.Printf("%s%-13s %v\n", indent, displayKey+":", value) 255 289 } ··· 259 293 func (opts *GetOptions) formatMetadataKey(key string) string { 260 294 // Replace underscores with spaces 261 295 formatted := strings.ReplaceAll(key, "_", " ") 262 - 296 + 263 297 // Split into words and capitalize each word 264 298 words := strings.Fields(formatted) 265 299 for i, word := range words { ··· 267 301 words[i] = strings.ToUpper(word[:1]) + word[1:] 268 302 } 269 303 } 270 - 304 + 271 305 return strings.Join(words, " ") 272 - } 306 + }
+129 -25
kubectl-hsm/pkg/commands/health.go
··· 20 20 "context" 21 21 "encoding/json" 22 22 "fmt" 23 + "os" 24 + "sort" 23 25 24 26 "github.com/spf13/cobra" 25 27 "sigs.k8s.io/yaml" 26 - 28 + 27 29 "github.com/evanjarrett/hsm-secrets-operator/kubectl-hsm/pkg/client" 28 30 ) 29 31 ··· 81 83 return fmt.Errorf("failed to connect to HSM operator: %w", err) 82 84 } 83 85 84 - // Get health status 86 + // Get health status, device status, and device info in parallel 85 87 health, err := hsmClient.GetHealth(ctx) 86 88 if err != nil { 87 89 return fmt.Errorf("failed to get health status: %w", err) 88 90 } 89 91 92 + var deviceStatus *client.DeviceStatusResponse 93 + var deviceInfo *client.DeviceInfoResponse 94 + 95 + // Get device information for enhanced health display 96 + if deviceStatus, err = hsmClient.GetDeviceStatus(ctx); err != nil { 97 + // Don't fail the health check if device status is unavailable 98 + if opts.Verbose { 99 + fmt.Fprintf(os.Stderr, "Warning: failed to get device status: %v\n", err) 100 + } 101 + } 102 + 103 + if deviceInfo, err = hsmClient.GetDeviceInfo(ctx); err != nil { 104 + // Don't fail the health check if device info is unavailable 105 + if opts.Verbose { 106 + fmt.Fprintf(os.Stderr, "Warning: failed to get device info: %v\n", err) 107 + } 108 + } 109 + 90 110 // Handle output formatting 91 111 switch opts.Output { 92 112 case "json": 93 - jsonBytes, err := json.MarshalIndent(health, "", " ") 113 + combinedOutput := map[string]interface{}{ 114 + "health": health, 115 + } 116 + if deviceStatus != nil { 117 + combinedOutput["deviceStatus"] = deviceStatus 118 + } 119 + if deviceInfo != nil { 120 + combinedOutput["deviceInfo"] = deviceInfo 121 + } 122 + jsonBytes, err := json.MarshalIndent(combinedOutput, "", " ") 94 123 if err != nil { 95 124 return fmt.Errorf("failed to marshal health status to JSON: %w", err) 96 125 } 97 126 fmt.Println(string(jsonBytes)) 98 127 case "yaml": 99 - yamlBytes, err := yaml.Marshal(health) 128 + combinedOutput := map[string]interface{}{ 129 + "health": health, 130 + } 131 + if deviceStatus != nil { 132 + combinedOutput["deviceStatus"] = deviceStatus 133 + } 134 + if deviceInfo != nil { 135 + combinedOutput["deviceInfo"] = deviceInfo 136 + } 137 + yamlBytes, err := yaml.Marshal(combinedOutput) 100 138 if err != nil { 101 139 return fmt.Errorf("failed to marshal health status to YAML: %w", err) 102 140 } 103 141 fmt.Print(string(yamlBytes)) 104 142 default: 105 - return opts.displayHealthText(health, cm.GetCurrentNamespace()) 143 + return opts.displayHealthText(health, deviceStatus, deviceInfo, cm.GetCurrentNamespace()) 106 144 } 107 145 108 146 return nil 109 147 } 110 148 111 149 // displayHealthText displays the health status in a human-readable format 112 - func (opts *HealthOptions) displayHealthText(health *client.HealthStatus, namespace string) error { 150 + func (opts *HealthOptions) displayHealthText(health *client.HealthStatus, deviceStatus *client.DeviceStatusResponse, deviceInfo *client.DeviceInfoResponse, namespace string) error { 113 151 fmt.Printf("HSM Operator Health Status\n") 114 152 fmt.Printf("==========================\n\n") 115 153 ··· 120 158 } else if health.Status == "unhealthy" { 121 159 statusEmoji = "❌" 122 160 } 123 - 161 + 124 162 fmt.Printf("Overall Status: %s %s\n", statusEmoji, health.Status) 125 163 fmt.Printf("Namespace: %s\n", namespace) 126 164 fmt.Printf("Check Time: %s\n", health.Timestamp.Format("2006-01-02 15:04:05 UTC")) 127 165 fmt.Printf("\n") 128 166 129 - // HSM connectivity 130 - hsmEmoji := "✅" 131 - if !health.HSMConnected { 132 - hsmEmoji = "❌" 167 + // Device Status Section 168 + if deviceStatus != nil && deviceStatus.TotalDevices > 0 { 169 + fmt.Printf("Device Status:\n") 170 + 171 + // Sort device names for consistent output 172 + var deviceNames []string 173 + for deviceName := range deviceStatus.Devices { 174 + deviceNames = append(deviceNames, deviceName) 175 + } 176 + sort.Strings(deviceNames) 177 + 178 + connectedCount := 0 179 + for _, deviceName := range deviceNames { 180 + connected := deviceStatus.Devices[deviceName] 181 + statusIcon := "🔴" 182 + statusText := "Disconnected" 183 + if connected { 184 + statusIcon = "🟢" 185 + statusText = "Connected" 186 + connectedCount++ 187 + } 188 + 189 + deviceInfoText := "" 190 + if deviceInfo != nil && deviceInfo.DeviceInfos[deviceName] != nil { 191 + info := deviceInfo.DeviceInfos[deviceName] 192 + if info.Manufacturer != "" && info.Model != "" { 193 + deviceInfoText = fmt.Sprintf(" (%s %s)", info.Manufacturer, info.Model) 194 + } 195 + } 196 + 197 + fmt.Printf(" %s %-15s %s%s\n", statusIcon, deviceName, statusText, deviceInfoText) 198 + } 199 + 200 + fmt.Printf("\n") 201 + fmt.Printf("Device Summary: %d/%d connected\n", connectedCount, deviceStatus.TotalDevices) 202 + } else { 203 + // Fallback to basic HSM connectivity info 204 + hsmEmoji := "✅" 205 + if !health.HSMConnected { 206 + hsmEmoji = "❌" 207 + } 208 + fmt.Printf("HSM Connected: %s %t\n", hsmEmoji, health.HSMConnected) 133 209 } 134 - fmt.Printf("HSM Connected: %s %t\n", hsmEmoji, health.HSMConnected) 135 - 210 + 136 211 // Replication status 137 212 replicationEmoji := "✅" 138 213 if !health.ReplicationEnabled { 139 214 replicationEmoji = "⚠️" 140 215 } 141 - fmt.Printf("Replication: %s %t\n", replicationEmoji, health.ReplicationEnabled) 142 - fmt.Printf("Active Nodes: %d\n", health.ActiveNodes) 216 + fmt.Printf("Replication: %s %t (%d nodes)\n", replicationEmoji, health.ReplicationEnabled, health.ActiveNodes) 143 217 fmt.Printf("\n") 144 218 145 - // Recommendations 146 - if !health.HSMConnected { 219 + // Enhanced recommendations based on device status 220 + if deviceStatus != nil { 221 + connectedCount := 0 222 + for _, connected := range deviceStatus.Devices { 223 + if connected { 224 + connectedCount++ 225 + } 226 + } 227 + 228 + if connectedCount == 0 { 229 + fmt.Printf("⚠️ Recommendations:\n") 230 + fmt.Printf(" • All devices are disconnected - check device connections\n") 231 + fmt.Printf(" • Verify HSM agent pods are running: kubectl get pods -l app.kubernetes.io/name=hsm-agent\n") 232 + fmt.Printf(" • Check agent logs for connection errors\n") 233 + } else if connectedCount < deviceStatus.TotalDevices { 234 + fmt.Printf("⚠️ Recommendations:\n") 235 + fmt.Printf(" • Some devices are disconnected - check device connections\n") 236 + fmt.Printf(" • Verify all agent pods are healthy\n") 237 + } else if connectedCount == 1 { 238 + fmt.Printf("💡 Recommendations:\n") 239 + fmt.Printf(" • Consider adding more HSM devices for high availability\n") 240 + fmt.Printf(" • Multiple devices enable automatic replication and failover\n") 241 + } 242 + } else if !health.HSMConnected { 147 243 fmt.Printf("⚠️ Recommendations:\n") 148 244 fmt.Printf(" • Check if HSM devices are connected and accessible\n") 149 245 fmt.Printf(" • Verify HSM agent pods are running: kubectl get pods -l app.kubernetes.io/component=agent\n") 150 246 fmt.Printf(" • Check agent logs for connection errors\n") 151 247 } 152 248 153 - if !health.ReplicationEnabled && health.ActiveNodes <= 1 { 154 - fmt.Printf("💡 Recommendations:\n") 155 - fmt.Printf(" • Consider adding more HSM devices for high availability\n") 156 - fmt.Printf(" • Multiple devices enable automatic replication and failover\n") 157 - } 158 - 159 249 // Overall assessment 160 250 if health.Status == "healthy" { 161 - fmt.Printf("🎉 All systems operational!\n") 251 + if deviceStatus != nil { 252 + connectedCount := 0 253 + for _, connected := range deviceStatus.Devices { 254 + if connected { 255 + connectedCount++ 256 + } 257 + } 258 + if connectedCount > 1 { 259 + fmt.Printf("🎉 All systems operational with %d devices providing high availability!\n", connectedCount) 260 + } else { 261 + fmt.Printf("🎉 All systems operational!\n") 262 + } 263 + } else { 264 + fmt.Printf("🎉 All systems operational!\n") 265 + } 162 266 } 163 267 164 268 return nil 165 - } 269 + }
+95 -18
kubectl-hsm/pkg/commands/list.go
··· 26 26 27 27 "github.com/spf13/cobra" 28 28 "sigs.k8s.io/yaml" 29 - 29 + 30 30 "github.com/evanjarrett/hsm-secrets-operator/kubectl-hsm/pkg/client" 31 31 ) 32 32 ··· 34 34 type ListOptions struct { 35 35 CommonOptions 36 36 AllNamespaces bool 37 + ShowDevices bool 37 38 } 38 39 39 40 // NewListCmd creates the list command ··· 65 66 } 66 67 67 68 cmd.Flags().BoolVar(&opts.AllNamespaces, "all-namespaces", false, "List secrets from all namespaces") 69 + cmd.Flags().BoolVar(&opts.ShowDevices, "show-devices", false, "Show device count and sync status for each secret") 68 70 cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Override the default namespace") 69 71 cmd.Flags().StringVarP(&opts.Output, "output", "o", "text", "Output format (text, json, yaml)") 70 72 cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including port forward details") ··· 98 100 return fmt.Errorf("failed to list secrets: %w", err) 99 101 } 100 102 103 + // Get device status if showing device information 104 + var deviceStatus *client.DeviceStatusResponse 105 + if opts.ShowDevices || opts.Output != "text" { 106 + deviceStatus, err = hsmClient.GetDeviceStatus(ctx) 107 + if err != nil && opts.Verbose { 108 + fmt.Fprintf(os.Stderr, "Warning: failed to get device status: %v\n", err) 109 + } 110 + } 111 + 101 112 // Handle output formatting 102 113 switch opts.Output { 103 114 case "json": ··· 106 117 "count": secretList.Count, 107 118 "secrets": secretList.Secrets, 108 119 } 120 + if deviceStatus != nil { 121 + cleanOutput["deviceStatus"] = deviceStatus 122 + } 109 123 jsonBytes, err := json.MarshalIndent(cleanOutput, "", " ") 110 124 if err != nil { 111 125 return fmt.Errorf("failed to marshal secrets to JSON: %w", err) 112 126 } 113 127 fmt.Println(string(jsonBytes)) 114 128 case "yaml": 115 - // Create clean output without pagination fields 129 + // Create clean output without pagination fields 116 130 cleanOutput := map[string]interface{}{ 117 131 "count": secretList.Count, 118 132 "secrets": secretList.Secrets, 119 133 } 134 + if deviceStatus != nil { 135 + cleanOutput["deviceStatus"] = deviceStatus 136 + } 120 137 yamlBytes, err := yaml.Marshal(cleanOutput) 121 138 if err != nil { 122 139 return fmt.Errorf("failed to marshal secrets to YAML: %w", err) 123 140 } 124 141 fmt.Print(string(yamlBytes)) 125 142 default: 126 - return opts.displaySecretsText(secretList, cm.GetCurrentNamespace()) 143 + return opts.displaySecretsText(secretList, deviceStatus, cm.GetCurrentNamespace()) 127 144 } 128 145 129 146 return nil 130 147 } 131 148 132 149 // displaySecretsText displays the secrets in a human-readable table format 133 - func (opts *ListOptions) displaySecretsText(secretList *client.SecretList, currentNamespace string) error { 150 + func (opts *ListOptions) displaySecretsText(secretList *client.SecretList, deviceStatus *client.DeviceStatusResponse, currentNamespace string) error { 134 151 if secretList == nil { 135 152 fmt.Println("No secrets found") 136 153 return nil ··· 150 167 // Create table writer 151 168 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 152 169 153 - // Print header 154 - if opts.AllNamespaces { 155 - fmt.Fprintln(w, "NAMESPACE\tNAME") 170 + // Print header based on options 171 + if opts.ShowDevices { 172 + if opts.AllNamespaces { 173 + fmt.Fprintln(w, "NAMESPACE\tNAME\tDEVICES\tSTATUS") 174 + } else { 175 + fmt.Fprintln(w, "NAME\tDEVICES\tSTATUS") 176 + } 156 177 } else { 157 - fmt.Fprintln(w, "NAME") 178 + if opts.AllNamespaces { 179 + fmt.Fprintln(w, "NAMESPACE\tNAME") 180 + } else { 181 + fmt.Fprintln(w, "NAME") 182 + } 158 183 } 159 184 160 - // Print each secret name 185 + // Print each secret name with device info if requested 161 186 for _, secret := range secrets { 162 - if opts.AllNamespaces { 163 - fmt.Fprintf(w, "%s\t%s\n", currentNamespace, secret) 187 + if opts.ShowDevices { 188 + deviceInfo := "" 189 + statusInfo := "" 190 + 191 + if deviceStatus != nil && deviceStatus.TotalDevices > 0 { 192 + connectedCount := 0 193 + for _, connected := range deviceStatus.Devices { 194 + if connected { 195 + connectedCount++ 196 + } 197 + } 198 + 199 + // Device count information 200 + if connectedCount > 1 { 201 + deviceInfo = fmt.Sprintf("🔗 %d", connectedCount) 202 + statusInfo = "synced" 203 + } else if connectedCount == 1 { 204 + deviceInfo = "📱 1" 205 + statusInfo = "single" 206 + } else { 207 + deviceInfo = "❌ 0" 208 + statusInfo = "unavailable" 209 + } 210 + } else { 211 + deviceInfo = "?" 212 + statusInfo = "unknown" 213 + } 214 + 215 + if opts.AllNamespaces { 216 + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", currentNamespace, secret, deviceInfo, statusInfo) 217 + } else { 218 + fmt.Fprintf(w, "%s\t%s\t%s\n", secret, deviceInfo, statusInfo) 219 + } 164 220 } else { 165 - fmt.Fprintf(w, "%s\n", secret) 221 + if opts.AllNamespaces { 222 + fmt.Fprintf(w, "%s\t%s\n", currentNamespace, secret) 223 + } else { 224 + fmt.Fprintf(w, "%s\n", secret) 225 + } 166 226 } 167 227 } 168 228 169 229 w.Flush() 170 230 171 - // Show summary 172 - fmt.Printf("\nTotal: %d secrets\n", secretList.Count) 231 + // Show enhanced summary 232 + fmt.Printf("\nTotal: %d secrets", secretList.Count) 233 + if opts.ShowDevices && deviceStatus != nil { 234 + connectedCount := 0 235 + for _, connected := range deviceStatus.Devices { 236 + if connected { 237 + connectedCount++ 238 + } 239 + } 240 + fmt.Printf(" • %d/%d devices connected", connectedCount, deviceStatus.TotalDevices) 241 + if connectedCount > 1 { 242 + fmt.Printf(" • ✅ Replication enabled") 243 + } else if connectedCount == 1 { 244 + fmt.Printf(" • ⚠️ Single device mode") 245 + } else { 246 + fmt.Printf(" • ❌ No devices available") 247 + } 248 + } 249 + fmt.Println() 173 250 174 251 return nil 175 252 } ··· 218 295 if bytes < 1024 { 219 296 return fmt.Sprintf("%dB", bytes) 220 297 } 221 - 298 + 222 299 units := []string{"B", "KB", "MB", "GB"} 223 300 size := float64(bytes) 224 301 unitIndex := 0 225 - 302 + 226 303 for unitIndex < len(units)-1 && size >= 1024 { 227 304 size /= 1024 228 305 unitIndex++ 229 306 } 230 - 307 + 231 308 if size == float64(int64(size)) { 232 309 return fmt.Sprintf("%.0f%s", size, units[unitIndex]) 233 310 } 234 311 return fmt.Sprintf("%.1f%s", size, units[unitIndex]) 235 - } 312 + }
+4 -4
kubectl-hsm/pkg/util/kubectl.go
··· 81 81 func (k *KubectlUtil) FindOperatorService(ctx context.Context) error { 82 82 svc, err := k.clientset.CoreV1().Services(k.namespace).Get(ctx, operatorServiceName, metav1.GetOptions{}) 83 83 if err != nil { 84 - return fmt.Errorf("HSM secrets operator service not found in namespace '%s': %w\n\nPlease check:\n - Is the operator installed? Try: kubectl get deploy -n %s\n - Are you in the correct namespace? Try: kubens <operator-namespace>", 84 + return fmt.Errorf("HSM secrets operator service not found in namespace '%s': %w\n\nPlease check:\n - Is the operator installed? Try: kubectl get deploy -n %s\n - Are you in the correct namespace? Try: kubens <operator-namespace>", 85 85 k.namespace, err, k.namespace) 86 86 } 87 87 ··· 174 174 dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) 175 175 176 176 ports := []string{fmt.Sprintf("%d:%d", pf.localPort, pf.remotePort)} 177 - 177 + 178 178 // Control output based on verbose flag 179 179 var stdout, stderr io.Writer 180 180 if pf.verbose { ··· 184 184 stdout = io.Discard 185 185 stderr = io.Discard 186 186 } 187 - 187 + 188 188 forwarder, err := portforward.New(dialer, ports, pf.stopCh, pf.readyCh, stdout, stderr) 189 189 if err != nil { 190 190 return fmt.Errorf("failed to create port forwarder: %w", err) ··· 262 262 } 263 263 264 264 return namespace, nil 265 - } 265 + }
+115 -7
web/app.js
··· 41 41 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`); 42 42 } 43 43 44 + async getDeviceStatus() { 45 + return this.request('/hsm/status'); 46 + } 47 + 48 + async getDeviceInfo() { 49 + return this.request('/hsm/info'); 50 + } 51 + 44 52 async createSecret(secretName, data, metadata = null) { 45 53 const requestBody = { data }; 46 54 if (metadata) { ··· 88 96 89 97 async loadInitialData() { 90 98 await this.checkAPIHealth(); 99 + await this.loadDeviceStatus(); 91 100 await this.loadSecrets(); 92 101 } 93 102 ··· 95 104 try { 96 105 const health = await this.api.getHealth(); 97 106 const statusElement = document.getElementById('apiStatus'); 107 + const deviceCountElement = document.getElementById('deviceCount'); 98 108 99 109 if (health.success && health.data.status === 'healthy') { 100 110 statusElement.textContent = '✅ Healthy'; ··· 103 113 statusElement.textContent = '⚠️ Degraded'; 104 114 statusElement.style.color = '#dd6b20'; 105 115 } 116 + 117 + // Update device count if available 118 + if (deviceCountElement && health.data.activeNodes !== undefined) { 119 + deviceCountElement.textContent = health.data.activeNodes; 120 + } 106 121 } catch (error) { 107 122 const statusElement = document.getElementById('apiStatus'); 108 123 statusElement.textContent = '❌ Error'; ··· 111 126 } 112 127 } 113 128 129 + async loadDeviceStatus() { 130 + const statusElement = document.getElementById('deviceStatus'); 131 + statusElement.innerHTML = '<div class="loading">Loading device status...</div>'; 132 + 133 + try { 134 + const [statusResponse, infoResponse] = await Promise.all([ 135 + this.api.getDeviceStatus(), 136 + this.api.getDeviceInfo() 137 + ]); 138 + 139 + const devices = statusResponse.data.devices || {}; 140 + const deviceInfos = infoResponse.data.deviceInfos || {}; 141 + const totalDevices = statusResponse.data.totalDevices || 0; 142 + 143 + this.renderDeviceStatus(devices, deviceInfos, totalDevices); 144 + } catch (error) { 145 + this.showError(statusElement, `Failed to load device status: ${error.message}`); 146 + } 147 + } 148 + 149 + renderDeviceStatus(devices, deviceInfos, totalDevices) { 150 + const statusElement = document.getElementById('deviceStatus'); 151 + 152 + if (totalDevices === 0) { 153 + statusElement.innerHTML = '<p style="text-align: center; color: #666; padding: 20px;">No HSM devices found.</p>'; 154 + return; 155 + } 156 + 157 + const deviceItems = Object.entries(devices).map(([deviceName, isConnected]) => { 158 + const info = deviceInfos[deviceName]; 159 + const statusIcon = isConnected ? '🟢' : '🔴'; 160 + const statusText = isConnected ? 'Connected' : 'Disconnected'; 161 + 162 + return ` 163 + <div class="device-item ${isConnected ? 'connected' : 'disconnected'}"> 164 + <div class="device-header"> 165 + <span class="device-name">${statusIcon} ${this.escapeHtml(deviceName)}</span> 166 + <span class="device-status-badge">${statusText}</span> 167 + </div> 168 + ${info ? ` 169 + <div class="device-details"> 170 + <div class="device-info"> 171 + <span>Manufacturer: ${this.escapeHtml(info.manufacturer || 'Unknown')}</span> 172 + <span>Model: ${this.escapeHtml(info.model || 'Unknown')}</span> 173 + <span>Serial: ${this.escapeHtml(info.serialNumber || 'Unknown')}</span> 174 + </div> 175 + </div> 176 + ` : ''} 177 + </div> 178 + `; 179 + }).join(''); 180 + 181 + statusElement.innerHTML = deviceItems; 182 + } 183 + 114 184 async loadSecrets() { 115 185 const listElement = document.getElementById('secretsList'); 116 186 listElement.innerHTML = '<div class="loading">Loading secrets...</div>'; ··· 166 236 const response = await this.api.getSecret(secretName); 167 237 const secretData = response.data; 168 238 239 + // Convert byte arrays to strings for display 240 + const displayData = {}; 241 + if (secretData.data) { 242 + for (const [key, value] of Object.entries(secretData.data)) { 243 + // Handle byte arrays by converting to string 244 + if (Array.isArray(value)) { 245 + displayData[key] = String.fromCharCode.apply(null, value); 246 + } else { 247 + displayData[key] = value; 248 + } 249 + } 250 + } 251 + 252 + const deviceBadge = secretData.deviceCount > 1 ? 253 + `<span class="device-badge multi-device">${secretData.deviceCount} devices</span>` : 254 + `<span class="device-badge single-device">1 device</span>`; 255 + 169 256 detailsElement.innerHTML = ` 170 - <h3>Secret: ${this.escapeHtml(secretName)}</h3> 171 - <div style="margin: 15px 0;"> 172 - <strong>Path:</strong> ${this.escapeHtml(secretData.path || secretName)}<br> 173 - <strong>Checksum:</strong> ${this.escapeHtml(secretData.checksum || 'N/A')}<br> 174 - <strong>Size:</strong> ${secretData.data ? Object.keys(secretData.data).length : 0} keys 257 + <h3>Secret: ${this.escapeHtml(secretName)} ${deviceBadge}</h3> 258 + <div class="secret-metadata"> 259 + <div class="metadata-item"> 260 + <strong>Path:</strong> ${this.escapeHtml(secretData.path || secretName)} 261 + </div> 262 + <div class="metadata-item"> 263 + <strong>Checksum:</strong> ${this.escapeHtml(secretData.checksum || 'N/A')} 264 + </div> 265 + <div class="metadata-item"> 266 + <strong>Keys:</strong> ${Object.keys(displayData).length} 267 + </div> 268 + ${secretData.deviceCount ? ` 269 + <div class="metadata-item"> 270 + <strong>Device Count:</strong> ${secretData.deviceCount} 271 + </div> 272 + ` : ''} 175 273 </div> 176 - <div> 274 + <div class="secret-data"> 177 275 <strong>Data:</strong> 178 - <div class="json-preview">${this.escapeHtml(JSON.stringify(secretData.data || {}, null, 2))}</div> 276 + <div class="json-preview">${this.escapeHtml(JSON.stringify(displayData, null, 2))}</div> 179 277 </div> 180 278 `; 181 279 } catch (error) { ··· 453 551 await this.loadSecrets(); 454 552 } 455 553 554 + async refreshDeviceStatus() { 555 + await this.loadDeviceStatus(); 556 + } 557 + 558 + async refreshAll() { 559 + await this.loadInitialData(); 560 + } 561 + 456 562 showError(element, message) { 457 563 const errorHTML = `<div class="error">❌ ${this.escapeHtml(message)}</div>`; 458 564 if (element) { ··· 516 622 517 623 // Expose functions globally for onclick handlers 518 624 window.refreshSecrets = () => ui.refreshSecrets(); 625 + window.refreshDeviceStatus = () => ui.refreshDeviceStatus(); 626 + window.refreshAll = () => ui.refreshAll(); 519 627 window.showCreateForm = () => ui.showCreateForm(); 520 628 window.hideCreateForm = () => ui.hideCreateForm(); 521 629 window.hideViewSection = () => ui.hideViewSection();
+15
web/index.html
··· 22 22 <div class="stat-number" id="apiStatus">-</div> 23 23 <div class="stat-label">API Status</div> 24 24 </div> 25 + <div class="stat-card"> 26 + <div class="stat-number" id="deviceCount">-</div> 27 + <div class="stat-label">HSM Devices</div> 28 + </div> 29 + </div> 30 + 31 + <div class="section" id="deviceSection"> 32 + <h2>🔌 HSM Device Status</h2> 33 + <div class="toolbar"> 34 + <button class="btn btn-secondary" onclick="refreshDeviceStatus()">🔄 Refresh Devices</button> 35 + <button class="btn btn-secondary" onclick="refreshAll()">🔄 Refresh All</button> 36 + </div> 37 + <div id="deviceStatus" class="device-status"> 38 + <div class="loading">Loading device status...</div> 39 + </div> 25 40 </div> 26 41 27 42 <div class="section">
+104
web/styles.css
··· 4 4 padding: 0; 5 5 } 6 6 7 + /* Multi-device support styles */ 8 + .device-status { 9 + display: flex; 10 + flex-direction: column; 11 + gap: 15px; 12 + } 13 + 14 + .device-item { 15 + border: 2px solid #e2e8f0; 16 + border-radius: 8px; 17 + padding: 15px; 18 + transition: border-color 0.3s ease; 19 + } 20 + 21 + .device-item.connected { 22 + border-color: #48bb78; 23 + background-color: #f0fff4; 24 + } 25 + 26 + .device-item.disconnected { 27 + border-color: #f56565; 28 + background-color: #fff5f5; 29 + } 30 + 31 + .device-header { 32 + display: flex; 33 + justify-content: space-between; 34 + align-items: center; 35 + margin-bottom: 10px; 36 + } 37 + 38 + .device-name { 39 + font-weight: bold; 40 + font-size: 1.1em; 41 + } 42 + 43 + .device-status-badge { 44 + padding: 4px 8px; 45 + border-radius: 4px; 46 + font-size: 0.9em; 47 + } 48 + 49 + .device-item.connected .device-status-badge { 50 + background-color: #48bb78; 51 + color: white; 52 + } 53 + 54 + .device-item.disconnected .device-status-badge { 55 + background-color: #f56565; 56 + color: white; 57 + } 58 + 59 + .device-details { 60 + margin-top: 10px; 61 + } 62 + 63 + .device-info { 64 + display: flex; 65 + flex-wrap: wrap; 66 + gap: 15px; 67 + font-size: 0.9em; 68 + color: #666; 69 + } 70 + 71 + .device-badge { 72 + padding: 2px 8px; 73 + border-radius: 12px; 74 + font-size: 0.8em; 75 + font-weight: bold; 76 + margin-left: 10px; 77 + } 78 + 79 + .device-badge.single-device { 80 + background-color: #e2e8f0; 81 + color: #4a5568; 82 + } 83 + 84 + .device-badge.multi-device { 85 + background-color: #667eea; 86 + color: white; 87 + } 88 + 89 + .secret-metadata { 90 + display: grid; 91 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 92 + gap: 10px; 93 + margin: 15px 0; 94 + padding: 15px; 95 + background-color: #f7fafc; 96 + border-radius: 6px; 97 + } 98 + 99 + .metadata-item { 100 + font-size: 0.9em; 101 + } 102 + 103 + .metadata-item strong { 104 + color: #4a5568; 105 + } 106 + 107 + .secret-data { 108 + margin-top: 20px; 109 + } 110 + 7 111 body { 8 112 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 113 background-color: #f5f5f5;