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..5ef3c00e 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..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,6 +156,7 @@ func (r *podRepo) fetchContainerInfo(pod *corev1.Pod) []*biz.Container { Namespace: pod.Namespace, CreateTime: r.GetCreateTime(pod), ContainerDevices: containerDevices, + Labels: 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 +}