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
+}