fix: YAML查看器按钮文案 '清理并编辑' → '编辑',统一简洁命名
This commit is contained in:
1 parent
a8b4af4409
commit
4bd9fa629b
14 files changed
+2146
-400
No files matched your search
@@ -0,0 +1,244 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// yamlEntry 表示一个待打包的 YAML 资源
|
||||
type yamlEntry struct {
|
||||
folder string // tar 内子目录
|
||||
name string // 文件名(不含 .yaml)
|
||||
yaml []byte // 清理后的 YAML
|
||||
}
|
||||
|
||||
// ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
|
||||
func (h *KubernetesHandler) ExportResourcesNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "cluster not found"})
|
||||
return
|
||||
}
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
ns := c.Param("namespace")
|
||||
if ns == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "message": "namespace is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 资源类型 → API 路径模板 + 子目录名
|
||||
type resourceDef struct {
|
||||
apiPathTmpl string // %s = namespace
|
||||
folder string // tar 内子目录
|
||||
kind string
|
||||
apiVersion string
|
||||
}
|
||||
resourceDefs := []resourceDef{
|
||||
{"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"},
|
||||
{"/api/v1/namespaces/%s/services", "services", "Service", "v1"},
|
||||
{"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"},
|
||||
{"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"},
|
||||
{"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"},
|
||||
}
|
||||
|
||||
// 收集所有资源的 YAML
|
||||
var entries []yamlEntry
|
||||
totalCount := 0
|
||||
|
||||
for _, rd := range resourceDefs {
|
||||
listPath := fmt.Sprintf(rd.apiPathTmpl, ns)
|
||||
yamlBytes, err := client.GetResourceYAML(listPath)
|
||||
if err != nil {
|
||||
log.Printf("[ExportResources] list %s failed: %v", rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 YAML list(K8s List 返回 kind: XxxList)
|
||||
var listObj map[string]interface{}
|
||||
if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil {
|
||||
log.Printf("[ExportResources] parse %s list failed: %v", rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items, ok := listObj["items"].([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取资源名
|
||||
meta, _ := itemMap["metadata"].(map[string]interface{})
|
||||
name, _ := meta["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 设置 apiVersion 和 kind
|
||||
itemMap["apiVersion"] = rd.apiVersion
|
||||
itemMap["kind"] = rd.kind
|
||||
|
||||
// 清理运行时字段
|
||||
cleanResourceYAML(itemMap)
|
||||
|
||||
// 序列化为 YAML
|
||||
cleanedBytes, err := yaml.Marshal(itemMap)
|
||||
if err != nil {
|
||||
log.Printf("[ExportResources] marshal %s/%s failed: %v", rd.folder, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, yamlEntry{
|
||||
folder: rd.folder,
|
||||
name: name,
|
||||
yaml: cleanedBytes,
|
||||
})
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalCount == 0 {
|
||||
c.JSON(404, gin.H{"code": 404, "message": "命名空间 " + ns + " 没有可导出的资源"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 tar.gz
|
||||
tarGz, err := buildTarGz(entries, ns)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s-%s.tar.gz", ns, time.Now().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "application/gzip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz)))
|
||||
c.Data(http.StatusOK, "application/gzip", tarGz)
|
||||
|
||||
log.Printf("[ExportResources] exported %d resources from namespace %s (cluster %d)", totalCount, ns, clusterID)
|
||||
}
|
||||
|
||||
// cleanResourceYAML 清理 K8s 资源中的运行时字段(通用,适用于所有资源类型)
|
||||
func cleanResourceYAML(obj map[string]interface{}) {
|
||||
// 清理 metadata
|
||||
if meta, ok := obj["metadata"].(map[string]interface{}); ok {
|
||||
delete(meta, "managedFields")
|
||||
delete(meta, "creationTimestamp")
|
||||
delete(meta, "resourceVersion")
|
||||
delete(meta, "uid")
|
||||
delete(meta, "selfLink")
|
||||
delete(meta, "generation")
|
||||
delete(meta, "ownerReferences")
|
||||
delete(meta, "finalizers")
|
||||
delete(meta, "generateName")
|
||||
|
||||
// 清理运行时 annotations
|
||||
if annotations, ok := meta["annotations"].(map[string]interface{}); ok {
|
||||
cleanRuntimeAnnotations(annotations)
|
||||
if len(annotations) == 0 {
|
||||
delete(meta, "annotations")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 status
|
||||
delete(obj, "status")
|
||||
|
||||
// 清理 spec 中的运行时字段
|
||||
if spec, ok := obj["spec"].(map[string]interface{}); ok {
|
||||
// Service: 移除动态分配的 clusterIP 和 nodePorts
|
||||
if obj["kind"] == "Service" {
|
||||
delete(spec, "clusterIP")
|
||||
delete(spec, "clusterIPs")
|
||||
// nodePorts 在 ports 数组内 — 仅移除 NodePort 类型的 nodePort 字段
|
||||
if ports, ok := spec["ports"].([]interface{}); ok {
|
||||
for _, p := range ports {
|
||||
if pm, ok := p.(map[string]interface{}); ok {
|
||||
delete(pm, "nodePort")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deployment: 清理 template.metadata 和 template.spec 中的运行时字段
|
||||
if obj["kind"] == "Deployment" {
|
||||
if template, ok := spec["template"].(map[string]interface{}); ok {
|
||||
if tMeta, ok := template["metadata"].(map[string]interface{}); ok {
|
||||
delete(tMeta, "creationTimestamp")
|
||||
if tAnn, ok := tMeta["annotations"].(map[string]interface{}); ok {
|
||||
cleanRuntimeAnnotations(tAnn)
|
||||
if len(tAnn) == 0 {
|
||||
delete(tMeta, "annotations")
|
||||
}
|
||||
}
|
||||
}
|
||||
if tSpec, ok := template["spec"].(map[string]interface{}); ok {
|
||||
delete(tSpec, "nodeName")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanRuntimeAnnotations 移除 K8s 系统/运行时 annotations
|
||||
func cleanRuntimeAnnotations(annotations map[string]interface{}) {
|
||||
runtimeKeys := []string{
|
||||
"kubectl.kubernetes.io/last-applied-configuration",
|
||||
"deployment.kubernetes.io/revision",
|
||||
"control-plane.alpha.kubernetes.io/leader",
|
||||
"autoscaling.alpha.kubernetes.io/conditions",
|
||||
"pv.kubernetes.io/bind-completed",
|
||||
"pv.kubernetes.io/bound-by-controller",
|
||||
}
|
||||
for _, k := range runtimeKeys {
|
||||
delete(annotations, k)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTarGz 构建 tar.gz 包
|
||||
func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
for _, e := range entries {
|
||||
// 路径: {namespace}/{folder}/{name}.yaml
|
||||
path := fmt.Sprintf("%s/%s/%s.yaml", namespace, e.folder, e.name)
|
||||
header := &tar.Header{
|
||||
Name: path,
|
||||
Mode: 0644,
|
||||
Size: int64(len(e.yaml)),
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return nil, fmt.Errorf("tar write header %s: %w", path, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil {
|
||||
return nil, fmt.Errorf("tar write body %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -1678,22 +1678,40 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
svcs, err := client.ListServices()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type SvcPortDetail struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Port int `json:"port"`
|
||||
TargetPort interface{} `json:"targetPort"`
|
||||
NodePort int `json:"nodePort,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
type SvcSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ClusterIP string `json:"cluster_ip"`
|
||||
Ports int `json:"ports"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ClusterIP string `json:"cluster_ip"`
|
||||
Ports int `json:"ports"`
|
||||
PortList []SvcPortDetail `json:"portList,omitempty"`
|
||||
Selector map[string]string `json:"selector,omitempty"`
|
||||
}
|
||||
result := []SvcSummary{}
|
||||
for _, s := range svcs.Items {
|
||||
if s.Metadata.Namespace != ns { continue }
|
||||
ports := []SvcPortDetail{}
|
||||
for _, p := range s.Spec.Ports {
|
||||
ports = append(ports, SvcPortDetail{
|
||||
Name: p.Name, Port: p.Port, TargetPort: p.TargetPort,
|
||||
NodePort: p.NodePort, Protocol: p.Protocol,
|
||||
})
|
||||
}
|
||||
result = append(result, SvcSummary{
|
||||
Namespace: s.Metadata.Namespace,
|
||||
Name: s.Metadata.Name,
|
||||
Type: s.Spec.Type,
|
||||
ClusterIP: s.Spec.ClusterIP,
|
||||
Ports: len(s.Spec.Ports),
|
||||
Namespace: s.Metadata.Namespace,
|
||||
Name: s.Metadata.Name,
|
||||
Type: s.Spec.Type,
|
||||
ClusterIP: s.Spec.ClusterIP,
|
||||
Ports: len(s.Spec.Ports),
|
||||
PortList: ports,
|
||||
Selector: s.Spec.Selector,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
@@ -1705,22 +1723,57 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ings, err := client.ListIngresses()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type PathDetail struct {
|
||||
Path string `json:"path"`
|
||||
PathType string `json:"pathType,omitempty"`
|
||||
BackendSvc string `json:"backendService,omitempty"`
|
||||
BackendPort string `json:"backendPort,omitempty"`
|
||||
}
|
||||
type RuleDetail struct {
|
||||
Host string `json:"host"`
|
||||
Paths []PathDetail `json:"paths"`
|
||||
}
|
||||
type IngSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Hosts []string `json:"hosts"`
|
||||
TLS bool `json:"tls"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Hosts []string `json:"hosts"`
|
||||
TLS bool `json:"tls"`
|
||||
IngressClass string `json:"ingressClass,omitempty"`
|
||||
Rules []RuleDetail `json:"rules,omitempty"`
|
||||
}
|
||||
result := []IngSummary{}
|
||||
for _, ing := range ings.Items {
|
||||
if ing.Metadata.Namespace != ns { continue }
|
||||
hosts := []string{}
|
||||
for _, rule := range ing.Spec.Rules { if rule.Host != "" { hosts = append(hosts, rule.Host) } }
|
||||
rules := []RuleDetail{}
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
if rule.Host != "" { hosts = append(hosts, rule.Host) }
|
||||
rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}}
|
||||
if rule.HTTP != nil {
|
||||
for _, p := range rule.HTTP.Paths {
|
||||
pd := PathDetail{Path: p.Path, PathType: p.PathType, BackendSvc: p.Backend.Service.Name}
|
||||
if p.Backend.Service.Port.Number > 0 {
|
||||
pd.BackendPort = strconv.Itoa(p.Backend.Service.Port.Number)
|
||||
} else if p.Backend.Service.Port.Name != "" {
|
||||
pd.BackendPort = p.Backend.Service.Port.Name
|
||||
}
|
||||
rd.Paths = append(rd.Paths, pd)
|
||||
}
|
||||
}
|
||||
rules = append(rules, rd)
|
||||
}
|
||||
// ingressClassName from annotations
|
||||
ingClass := ""
|
||||
if ing.Metadata.Annotations != nil {
|
||||
ingClass = ing.Metadata.Annotations["kubernetes.io/ingress.class"]
|
||||
}
|
||||
result = append(result, IngSummary{
|
||||
Namespace: ing.Metadata.Namespace,
|
||||
Name: ing.Metadata.Name,
|
||||
Hosts: hosts,
|
||||
TLS: len(ing.Spec.TLS) > 0,
|
||||
Namespace: ing.Metadata.Namespace,
|
||||
Name: ing.Metadata.Name,
|
||||
Hosts: hosts,
|
||||
TLS: len(ing.Spec.TLS) > 0,
|
||||
IngressClass: ingClass,
|
||||
Rules: rules,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
@@ -2243,6 +2296,55 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
|
||||
c.JSON(200, models.Response{Code: 200, Message: "YAML已应用"})
|
||||
}
|
||||
|
||||
// resourceAPIPath builds the K8s API path for common resource types (shared by Get/Update/Delete)
|
||||
func resourceAPIPath(ns, resource, name string) (string, error) {
|
||||
switch resource {
|
||||
case "deployments", "deployment":
|
||||
return fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name), nil
|
||||
case "statefulsets", "statefulset":
|
||||
return fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name), nil
|
||||
case "daemonsets", "daemonset":
|
||||
return fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name), nil
|
||||
case "replicasets", "replicaset":
|
||||
return fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name), nil
|
||||
case "pods", "pod":
|
||||
return fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name), nil
|
||||
case "services", "service":
|
||||
return fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name), nil
|
||||
case "ingresses", "ingress":
|
||||
return fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name), nil
|
||||
case "configmaps", "configmap":
|
||||
return fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name), nil
|
||||
case "secrets", "secret":
|
||||
return fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name), nil
|
||||
case "pvcs", "persistentvolumeclaim":
|
||||
return fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name), nil
|
||||
case "hpas", "hpa", "horizontalpodautoscaler":
|
||||
return fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name), nil
|
||||
case "cronjobs", "cronjob":
|
||||
return fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported resource type: %s", resource)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.)
|
||||
func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
resource := c.Param("resource")
|
||||
name := c.Param("name")
|
||||
apiPath, err := resourceAPIPath(ns, resource, name)
|
||||
if err != nil { c.JSON(400, models.Response{Code: 400, Message: err.Error()}); return }
|
||||
if err := client.DeleteResource(apiPath); err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Message: "资源已删除"})
|
||||
}
|
||||
|
||||
// getK8sClient builds a K8s API client from cluster's stored kubeconfig
|
||||
func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *models.KubernetesCluster, error) {
|
||||
var cluster models.KubernetesCluster
|
||||
|
||||
@@ -397,6 +397,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
kubernetesRouter.GET("/:clusterCode/capacity", k8sHandler.GetCapacityAnalysis)
|
||||
// 🆕 YAML Apply
|
||||
kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML)
|
||||
// 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/export", k8sHandler.ExportResourcesNS)
|
||||
|
||||
// PVC (存储)
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pvcs", k8sHandler.ListPVCsNS)
|
||||
@@ -409,6 +411,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
// YAML view/edit
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS)
|
||||
kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS)
|
||||
kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS)
|
||||
}
|
||||
|
||||
// 告警路由
|
||||
|
||||
@@ -349,7 +349,32 @@ type IngressTLS struct {
|
||||
}
|
||||
|
||||
type IngressRule struct {
|
||||
Host string `json:"host"`
|
||||
Host string `json:"host"`
|
||||
HTTP *IngressHTTP `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
type IngressHTTP struct {
|
||||
Paths []IngressPath `json:"paths"`
|
||||
}
|
||||
|
||||
type IngressPath struct {
|
||||
Path string `json:"path"`
|
||||
PathType string `json:"pathType"`
|
||||
Backend IngressBackend `json:"backend"`
|
||||
}
|
||||
|
||||
type IngressBackend struct {
|
||||
Service IngressBackendService `json:"service"`
|
||||
}
|
||||
|
||||
type IngressBackendService struct {
|
||||
Name string `json:"name"`
|
||||
Port IngressServicePort `json:"port"`
|
||||
}
|
||||
|
||||
type IngressServicePort struct {
|
||||
Number int `json:"number,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectMeta
|
||||
@@ -1264,6 +1289,11 @@ func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error {
|
||||
return c.putRaw(apiPath, "application/yaml", yamlData)
|
||||
}
|
||||
|
||||
// DeleteResource deletes a K8s resource by apiPath (generic)
|
||||
func (c *Client) DeleteResource(apiPath string) error {
|
||||
return c.delete(apiPath)
|
||||
}
|
||||
|
||||
// applyRaw parses YAML and applies it via create-or-update pattern
|
||||
func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) {
|
||||
// Parse YAML to extract resource metadata
|
||||
|
||||
Reference in new issue
Block a user