From fc09ba950129a0ddb08012ae4f2439109fb09b0e Mon Sep 17 00:00:00 2001 From: pingxin403 Date: Sun, 5 Jul 2026 16:02:27 +0800 Subject: [PATCH 1/2] feat: display pod labels in task list and detail views Add Labels field to ContainerReply proto, biz model, and data layer. Populate labels from pod.Labels in fetchContainerInfo. Add Tags column to task list (first 2 labels + N overflow). Add Labels row to task detail basic info. Closes #85 Signed-off-by: pingxin403 --- .../projects/vgpu/views/task/admin/Detail.vue | 13 ++ .../projects/vgpu/views/task/admin/index.vue | 23 ++ packages/web/src/locales/en.js | 1 + packages/web/src/locales/zh.js | 1 + server/api/v1/container.proto | 1 + server/internal/biz/pod.go | 1 + server/internal/data/pod.go | 1 + server/internal/service/container.go | 2 + server/internal/service/container_test.go | 221 ++++++++++++++++++ 9 files changed, 264 insertions(+) create mode 100644 server/internal/service/container_test.go diff --git a/packages/web/projects/vgpu/views/task/admin/Detail.vue b/packages/web/projects/vgpu/views/task/admin/Detail.vue index 4d43f8a9..c033da6a 100644 --- a/packages/web/projects/vgpu/views/task/admin/Detail.vue +++ b/packages/web/projects/vgpu/views/task/admin/Detail.vue @@ -93,6 +93,19 @@ {{ $t('task.createTime') }} {{ basicCreateTime }} +
+ {{ $t('task.labels') }} + + {{ k }}={{ v }} + +
diff --git a/packages/web/projects/vgpu/views/task/admin/index.vue b/packages/web/projects/vgpu/views/task/admin/index.vue index 38007f35..f76f4738 100644 --- a/packages/web/projects/vgpu/views/task/admin/index.vue +++ b/packages/web/projects/vgpu/views/task/admin/index.vue @@ -179,6 +179,29 @@ const baseColumns = computed(() => [ ); }, }, + { + title: t('task.labels'), + dataIndex: 'labels', + render: ({ labels }) => { + if (!labels || typeof labels !== 'object') return --; + const entries = Object.entries(labels); + if (entries.length === 0) return --; + const visible = entries.slice(0, 2); + const remaining = entries.length - 2; + return ( +
+ {visible.map(([k, v]) => ( + + {k}={v} + + ))} + {remaining > 0 && ( + +{remaining} + )} +
+ ); + }, + }, { title: t('task.status'), dataIndex: 'status', diff --git a/packages/web/src/locales/en.js b/packages/web/src/locales/en.js index bf579fd2..ff782b01 100644 --- a/packages/web/src/locales/en.js +++ b/packages/web/src/locales/en.js @@ -274,6 +274,7 @@ export default { priority: 'Priority', appName: 'App Name', createTime: 'Creation Time', + labels: 'Labels', times: 'x', computeUsageTrend: 'GPU Compute Utilization (%)', memUsageTrend: 'GPU Memory Utilization (%)', diff --git a/packages/web/src/locales/zh.js b/packages/web/src/locales/zh.js index 83225670..796716a7 100644 --- a/packages/web/src/locales/zh.js +++ b/packages/web/src/locales/zh.js @@ -271,6 +271,7 @@ export default { priority: '优先级', appName: '应用名称', createTime: '创建时间', + labels: '标签', times: '倍', computeUsageTrend: 'GPU 算力使用率(%)', memUsageTrend: 'GPU 显存使用率(%)', diff --git a/server/api/v1/container.proto b/server/api/v1/container.proto index 04d97d73..616cac62 100644 --- a/server/api/v1/container.proto +++ b/server/api/v1/container.proto @@ -67,6 +67,7 @@ message ContainerReply { string namespace = 17; repeated string device_ids = 18; repeated string images = 19; + map labels = 20; } message ContainersReply { diff --git a/server/internal/biz/pod.go b/server/internal/biz/pod.go index 4162db75..97c16f72 100644 --- a/server/internal/biz/pod.go +++ b/server/internal/biz/pod.go @@ -22,6 +22,7 @@ type Container struct { Priority string NodeUID string Namespace string + Labels map[string]string } type PodInfo struct { diff --git a/server/internal/data/pod.go b/server/internal/data/pod.go index e17857ec..fdf8404b 100644 --- a/server/internal/data/pod.go +++ b/server/internal/data/pod.go @@ -152,6 +152,7 @@ func (r *podRepo) fetchContainerInfo(pod *corev1.Pod) []*biz.Container { Namespace: pod.Namespace, CreateTime: r.GetCreateTime(pod), ContainerDevices: containerDevices, + Labels: pod.Labels, } if len(containerDevices) > 0 { c.Priority = containerDevices[0].Priority diff --git a/server/internal/service/container.go b/server/internal/service/container.go index c108586d..b4722e23 100644 --- a/server/internal/service/container.go +++ b/server/internal/service/container.go @@ -81,6 +81,7 @@ func (s *ContainerService) GetAllContainers(ctx context.Context, req *pb.GetAllC containerReply.NodeUid = container.NodeUID containerReply.Namespace = container.Namespace containerReply.Priority = container.Priority + containerReply.Labels = container.Labels for _, containerDevice := range container.ContainerDevices { deviceID := containerDevice.UUID if device, err := s.node.FindDeviceByAliasId(containerDevice.UUID); err == nil { @@ -127,6 +128,7 @@ func (s *ContainerService) GetContainer(ctx context.Context, req *pb.GetContaine ctrReply.NodeUid = container.NodeUID ctrReply.Namespace = container.Namespace ctrReply.Priority = container.Priority + ctrReply.Labels = container.Labels allContainers, err := s.pod.ListAllContainers(ctx) if err == nil { images := make([]string, 0) diff --git a/server/internal/service/container_test.go b/server/internal/service/container_test.go new file mode 100644 index 00000000..365e7a8d --- /dev/null +++ b/server/internal/service/container_test.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "io" + "testing" + "time" + + "github.com/go-kratos/kratos/v2/log" + pb "vgpu/api/v1" + "vgpu/internal/biz" +) + +// mockPodRepo implements biz.PodRepo. +type mockPodRepo struct { + containers []*biz.Container + err error +} + +func (m *mockPodRepo) ListAll(_ context.Context) ([]*biz.Container, error) { + return m.containers, m.err +} + +func (m *mockPodRepo) FindOne(_ context.Context, podUID string, name string) (*biz.Container, error) { + for _, c := range m.containers { + if c.PodUID == podUID && c.Name == name { + return c, nil + } + } + return nil, m.err +} + +// mockNodeRepo implements biz.NodeRepo. +type mockNodeRepo struct{} + +func (m *mockNodeRepo) ListAll(_ context.Context) ([]*biz.Node, error) { + return nil, nil +} + +func (m *mockNodeRepo) GetNode(_ context.Context, _ string) (*biz.Node, error) { + return nil, nil +} + +func (m *mockNodeRepo) ListAllDevices(_ context.Context) ([]*biz.DeviceInfo, error) { + return nil, nil +} + +func (m *mockNodeRepo) FindDeviceByAliasId(_ string) (*biz.DeviceInfo, error) { + return nil, io.ErrUnexpectedEOF // return error so the service falls through to raw UUID +} + +func TestContainerService_GetAllContainers_Labels(t *testing.T) { + logger := log.NewStdLogger(io.Discard) + + mockPod := &mockPodRepo{ + containers: []*biz.Container{ + { + Name: "ctr-1", + PodUID: "pod-1", + PodName: "my-app", + Status: biz.ContainerStatusSuccess, + Namespace: "default", + NodeName: "node-1", + NodeUID: "node-uid-1", + Priority: "1", + Image: "nginx:latest", + ContainerDevices: biz.ContainerDevices{ + {UUID: "gpu-uuid-1", Type: "NVIDIA", Usedmem: 1000, Usedcores: 10}, + }, + Labels: map[string]string{ + "app": "test-app", + "version": "v1", + }, + }, + }, + } + + mockNode := &mockNodeRepo{} + + svc := NewContainerService(biz.NewNodeUsecase(mockNode, logger), biz.NewPodUseCase(mockPod, logger)) + + res, err := svc.GetAllContainers(context.Background(), &pb.GetAllContainersReq{ + Filters: &pb.GetAllContainersReq_Filters{}, + }) + if err != nil { + t.Fatalf("GetAllContainers failed: %v", err) + } + if len(res.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(res.Items)) + } + + ctr := res.Items[0] + labels := ctr.Labels + if labels == nil { + t.Fatal("expected Labels to be non-nil") + } + if len(labels) != 2 { + t.Fatalf("expected 2 labels, got %d", len(labels)) + } + if v, ok := labels["app"]; !ok || v != "test-app" { + t.Fatalf(`expected labels["app"] == "test-app", got %q`, v) + } + if v, ok := labels["version"]; !ok || v != "v1" { + t.Fatalf(`expected labels["version"] == "v1", got %q`, v) + } + + // Verify the rest of the mapping still works + if len(ctr.DeviceIds) != 1 || ctr.DeviceIds[0] != "gpu-uuid-1" { + t.Fatalf("expected DeviceIds [gpu-uuid-1], got %v", ctr.DeviceIds) + } +} + +func TestContainerService_GetContainer_Labels(t *testing.T) { + logger := log.NewStdLogger(io.Discard) + + mockPod := &mockPodRepo{ + containers: []*biz.Container{ + { + Name: "ctr-1", + PodUID: "pod-1", + PodName: "my-app", + Status: biz.ContainerStatusSuccess, + Namespace: "default", + NodeName: "node-1", + NodeUID: "node-uid-1", + Priority: "1", + Image: "nginx:latest", + CreateTime: mustParseTime("2025-01-01T00:00:00Z"), + ContainerDevices: biz.ContainerDevices{ + {UUID: "gpu-uuid-1", Type: "NVIDIA", Usedmem: 1000, Usedcores: 10}, + }, + Labels: map[string]string{ + "app": "test-app", + "version": "v1", + }, + }, + }, + } + + mockNode := &mockNodeRepo{} + + svc := NewContainerService(biz.NewNodeUsecase(mockNode, logger), biz.NewPodUseCase(mockPod, logger)) + + ctrReply, err := svc.GetContainer(context.Background(), &pb.GetContainerReq{ + PodUid: "pod-1", + Name: "ctr-1", + }) + if err != nil { + t.Fatalf("GetContainer failed: %v", err) + } + + labels := ctrReply.Labels + if labels == nil { + t.Fatal("expected Labels to be non-nil") + } + if len(labels) != 2 { + t.Fatalf("expected 2 labels, got %d", len(labels)) + } + if v, ok := labels["app"]; !ok || v != "test-app" { + t.Fatalf(`expected labels["app"] == "test-app", got %q`, v) + } + if v, ok := labels["version"]; !ok || v != "v1" { + t.Fatalf(`expected labels["version"] == "v1", got %q`, v) + } + + if len(ctrReply.DeviceIds) != 1 || ctrReply.DeviceIds[0] != "gpu-uuid-1" { + t.Fatalf("expected DeviceIds [gpu-uuid-1], got %v", ctrReply.DeviceIds) + } +} + +func TestContainerService_GetAllContainers_NilLabels(t *testing.T) { + logger := log.NewStdLogger(io.Discard) + + mockPod := &mockPodRepo{ + containers: []*biz.Container{ + { + Name: "ctr-nil-labels", + PodUID: "pod-2", + PodName: "no-labels-app", + Status: biz.ContainerStatusSuccess, + Namespace: "default", + NodeName: "node-1", + NodeUID: "node-uid-1", + Priority: "0", + Image: "alpine:latest", + ContainerDevices: biz.ContainerDevices{ + {UUID: "gpu-uuid-2", Type: "NVIDIA", Usedmem: 500, Usedcores: 5}, + }, + Labels: nil, + }, + }, + } + + mockNode := &mockNodeRepo{} + + svc := NewContainerService(biz.NewNodeUsecase(mockNode, logger), biz.NewPodUseCase(mockPod, logger)) + + res, err := svc.GetAllContainers(context.Background(), &pb.GetAllContainersReq{ + Filters: &pb.GetAllContainersReq_Filters{}, + }) + if err != nil { + t.Fatalf("GetAllContainers failed: %v", err) + } + if len(res.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(res.Items)) + } + + ctr := res.Items[0] + if ctr.Labels != nil { + t.Fatal("expected Labels to be nil for a container with nil labels") + } +} + +// mustParseTime is a test helper for parsing RFC3339 timestamps. +func mustParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} From 6751b40166cba1b74d93a532a0391631fb7d66f4 Mon Sep 17 00:00:00 2001 From: pingxin403 Date: Sun, 5 Jul 2026 16:47:14 +0800 Subject: [PATCH 2/2] fix: defensive copy of pod.Labels and add key to t-tag elements Address CodeRabbit review comments: - Copy pod.Labels map to avoid informer cache shared-mutation risk - Add missing :key to t-tag in task list Labels column Signed-off-by: pingxin403 --- packages/web/projects/vgpu/views/task/admin/index.vue | 2 +- server/internal/data/pod.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/web/projects/vgpu/views/task/admin/index.vue b/packages/web/projects/vgpu/views/task/admin/index.vue index f76f4738..5ef3c00e 100644 --- a/packages/web/projects/vgpu/views/task/admin/index.vue +++ b/packages/web/projects/vgpu/views/task/admin/index.vue @@ -191,7 +191,7 @@ const baseColumns = computed(() => [ return (
{visible.map(([k, v]) => ( - + {k}={v} ))} diff --git a/server/internal/data/pod.go b/server/internal/data/pod.go index fdf8404b..716cd1ba 100644 --- a/server/internal/data/pod.go +++ b/server/internal/data/pod.go @@ -139,6 +139,10 @@ func (r *podRepo) fetchContainerInfo(pod *corev1.Pod) []*biz.Container { if deviceIdx < len(bizContainerDevices) { containerDevices = bizContainerDevices[deviceIdx] } + labels := make(map[string]string, len(pod.Labels)) + for k, v := range pod.Labels { + labels[k] = v + } c := &biz.Container{ Name: ctr.Name, UUID: ctrIdMaps[ctr.Name], @@ -152,7 +156,7 @@ func (r *podRepo) fetchContainerInfo(pod *corev1.Pod) []*biz.Container { Namespace: pod.Namespace, CreateTime: r.GetCreateTime(pod), ContainerDevices: containerDevices, - Labels: pod.Labels, + Labels: labels, } if len(containerDevices) > 0 { c.Priority = containerDevices[0].Priority