feat: Support favorite operations for containers and images (#11049)

Refs #10035 #3372
This commit is contained in:
ssongliu 2025-11-24 15:55:14 +08:00 committed by GitHub
parent 063ed23acb
commit d7c9b3b192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 371 additions and 104 deletions

View file

@ -155,3 +155,24 @@ func (b *BaseApi) GetSettingByKey(c *gin.Context) {
value := settingService.GetSettingByKey(key)
helper.SuccessWithData(c, value)
}
// @Tags System Setting
// @Summary Save common description
// @Accept json
// @Param request body dto.CommonDescription true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /settings/description/save [post]
func (b *BaseApi) SaveDescription(c *gin.Context) {
var req dto.CommonDescription
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := settingService.SaveDescription(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}

View file

@ -40,6 +40,9 @@ type ContainerInfo struct {
AppName string `json:"appName"`
AppInstallName string `json:"appInstallName"`
Websites []string `json:"websites"`
IsPinned bool `json:"isPinned"`
Description string `json:"description"`
}
type ContainerOptions struct {

View file

@ -15,6 +15,9 @@ type ImageInfo struct {
IsUsed bool `json:"isUsed"`
Tags []string `json:"tags"`
Size int64 `json:"size"`
IsPinned bool `json:"isPinned"`
Description string `json:"description"`
}
type ImageLoad struct {

View file

@ -81,3 +81,11 @@ type SystemProxy struct {
User string `json:"user"`
Password string `json:"password"`
}
type CommonDescription struct {
ID string `json:"id" validate:"required"`
Type string `json:"type" validate:"required"`
DetailType string `json:"detailType"`
IsPinned bool `json:"isPinned"`
Description string `json:"description"`
}

View file

@ -7,6 +7,14 @@ type Setting struct {
About string `json:"about"`
}
type CommonDescription struct {
ID string `json:"id"`
Type string `json:"type"`
DetailType string `json:"detailType"`
IsPinned bool `json:"isPinned"`
Description string `json:"description"`
}
type NodeInfo struct {
Scope string `json:"scope"`
BaseDir string `json:"baseDir"`

View file

@ -77,6 +77,11 @@ func WithByType(tp string) DBOption {
return g.Where("`type` = ?", tp)
}
}
func WithByDetailType(tp string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("`detail_type` = ?", tp)
}
}
func WithTypes(types []string) DBOption {
return func(db *gorm.DB) *gorm.DB {

View file

@ -26,6 +26,13 @@ type ISettingRepo interface {
DelMonitorIO(timeForDelete time.Time) error
DelMonitorNet(timeForDelete time.Time) error
UpdateOrCreate(key, value string) error
GetDescription(opts ...DBOption) (model.CommonDescription, error)
GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error)
CreateDescription(data *model.CommonDescription) error
UpdateDescription(id string, val map[string]interface{}) error
DelDescription(id string) error
WithByDescriptionID(id string) DBOption
}
func NewISettingRepo() ISettingRepo {
@ -108,3 +115,36 @@ func (s *SettingRepo) UpdateOrCreate(key, value string) error {
}
return global.DB.Model(&setting).UpdateColumn("value", value).Error
}
func (s *SettingRepo) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) {
var lists []model.CommonDescription
db := global.DB.Model(&model.CommonDescription{})
for _, opt := range opts {
db = opt(db)
}
err := db.Find(&lists).Error
return lists, err
}
func (s *SettingRepo) GetDescription(opts ...DBOption) (model.CommonDescription, error) {
var data model.CommonDescription
db := global.DB.Model(&model.CommonDescription{})
for _, opt := range opts {
db = opt(db)
}
err := db.First(&data).Error
return data, err
}
func (s *SettingRepo) CreateDescription(data *model.CommonDescription) error {
return global.DB.Create(data).Error
}
func (s *SettingRepo) UpdateDescription(id string, val map[string]interface{}) error {
return global.DB.Model(&model.CommonDescription{}).Where("id = ?", id).Updates(val).Error
}
func (s *SettingRepo) DelDescription(id string) error {
return global.DB.Where("id = ?", id).Delete(&model.CommonDescription{}).Error
}
func (s *SettingRepo) WithByDescriptionID(id string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("id = ?", id)
}
}

View file

@ -96,10 +96,6 @@ func NewIContainerService() IContainerService {
}
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
var (
records []container.Summary
list []container.Summary
)
client, err := docker.NewDockerClient()
if err != nil {
return 0, nil, err
@ -117,114 +113,32 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
if err != nil {
return 0, nil, err
}
if req.ExcludeAppStore {
for _, item := range containers {
if created, ok := item.Labels[composeCreatedBy]; ok && created == "Apps" {
continue
}
list = append(list, item)
}
} else {
list = containers
}
records := searchWithFilter(req, containers)
if len(req.Name) != 0 {
length, count := len(list), 0
for count < length {
if !strings.Contains(list[count].Names[0][1:], req.Name) && !strings.Contains(list[count].Image, req.Name) {
list = append(list[:count], list[(count+1):]...)
length--
} else {
count++
}
}
}
if req.State != "all" {
length, count := len(list), 0
for count < length {
if list[count].State != req.State {
list = append(list[:count], list[(count+1):]...)
length--
} else {
count++
}
}
}
switch req.OrderBy {
case "name":
sort.Slice(list, func(i, j int) bool {
if req.Order == constant.OrderAsc {
return list[i].Names[0][1:] < list[j].Names[0][1:]
}
return list[i].Names[0][1:] > list[j].Names[0][1:]
})
default:
sort.Slice(list, func(i, j int) bool {
if req.Order == constant.OrderAsc {
return list[i].Created < list[j].Created
}
return list[i].Created > list[j].Created
})
}
total, start, end := len(list), (req.Page-1)*req.PageSize, req.Page*req.PageSize
var backData []dto.ContainerInfo
total, start, end := len(records), (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
records = make([]container.Summary, 0)
backData = make([]dto.ContainerInfo, 0)
} else {
if end >= total {
end = total
}
records = list[start:end]
backData = records[start:end]
}
backDatas := make([]dto.ContainerInfo, len(records))
for i := 0; i < len(records); i++ {
item := records[i]
IsFromCompose := false
if _, ok := item.Labels[composeProjectLabel]; ok {
IsFromCompose = true
}
IsFromApp := false
if created, ok := item.Labels[composeCreatedBy]; ok && created == "Apps" {
IsFromApp = true
}
exposePorts := transPortToStr(records[i].Ports)
info := dto.ContainerInfo{
ContainerID: item.ID,
CreateTime: time.Unix(item.Created, 0).Format(constant.DateTimeLayout),
Name: item.Names[0][1:],
ImageId: strings.Split(item.ImageID, ":")[1],
ImageName: item.Image,
State: item.State,
RunTime: item.Status,
Ports: exposePorts,
IsFromApp: IsFromApp,
IsFromCompose: IsFromCompose,
SizeRw: item.SizeRw,
SizeRootFs: item.SizeRootFs,
}
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(info.Name))
for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 {
info.AppInstallName = install.Name
info.AppName = install.App.Name
backData[i].AppInstallName = install.Name
backData[i].AppName = install.App.Name
websites, _ := websiteRepo.GetBy(websiteRepo.WithAppInstallId(install.ID))
for _, website := range websites {
info.Websites = append(info.Websites, website.PrimaryDomain)
backData[i].Websites = append(backData[i].Websites, website.PrimaryDomain)
}
}
backDatas[i] = info
if item.NetworkSettings != nil && len(item.NetworkSettings.Networks) > 0 {
networks := make([]string, 0, len(item.NetworkSettings.Networks))
for key := range item.NetworkSettings.Networks {
networks = append(networks, item.NetworkSettings.Networks[key].IPAddress)
}
sort.Strings(networks)
backDatas[i].Network = networks
}
}
return int64(total), backDatas, nil
return int64(total), backData, nil
}
func (u *ContainerService) List() []dto.ContainerOptions {
@ -1771,3 +1685,110 @@ func loadContainerPortForInfo(itemPorts []container.Port) []dto.PortHelper {
}
return exposedPorts
}
func searchWithFilter(req dto.PageContainer, containers []container.Summary) []dto.ContainerInfo {
var (
records []dto.ContainerInfo
list []container.Summary
)
if req.ExcludeAppStore {
for _, item := range containers {
if created, ok := item.Labels[composeCreatedBy]; ok && created == "Apps" {
continue
}
list = append(list, item)
}
} else {
list = containers
}
if len(req.Name) != 0 {
length, count := len(list), 0
for count < length {
if !strings.Contains(list[count].Names[0][1:], req.Name) && !strings.Contains(list[count].Image, req.Name) {
list = append(list[:count], list[(count+1):]...)
length--
} else {
count++
}
}
}
if req.State != "all" {
length, count := len(list), 0
for count < length {
if list[count].State != req.State {
list = append(list[:count], list[(count+1):]...)
length--
} else {
count++
}
}
}
switch req.OrderBy {
case "name":
sort.Slice(list, func(i, j int) bool {
if req.Order == constant.OrderAsc {
return list[i].Names[0][1:] < list[j].Names[0][1:]
}
return list[i].Names[0][1:] > list[j].Names[0][1:]
})
default:
sort.Slice(list, func(i, j int) bool {
if req.Order == constant.OrderAsc {
return list[i].Created < list[j].Created
}
return list[i].Created > list[j].Created
})
}
for _, item := range list {
IsFromCompose := false
if _, ok := item.Labels[composeProjectLabel]; ok {
IsFromCompose = true
}
IsFromApp := false
if created, ok := item.Labels[composeCreatedBy]; ok && created == "Apps" {
IsFromApp = true
}
exposePorts := transPortToStr(item.Ports)
info := dto.ContainerInfo{
ContainerID: item.ID,
CreateTime: time.Unix(item.Created, 0).Format(constant.DateTimeLayout),
Name: item.Names[0][1:],
Ports: exposePorts,
ImageId: strings.Split(item.ImageID, ":")[1],
ImageName: item.Image,
State: item.State,
RunTime: item.Status,
SizeRw: item.SizeRw,
SizeRootFs: item.SizeRootFs,
IsFromApp: IsFromApp,
IsFromCompose: IsFromCompose,
}
if item.NetworkSettings != nil && len(item.NetworkSettings.Networks) > 0 {
networks := make([]string, 0, len(item.NetworkSettings.Networks))
for key := range item.NetworkSettings.Networks {
networks = append(networks, item.NetworkSettings.Networks[key].IPAddress)
}
sort.Strings(networks)
info.Network = networks
}
records = append(records, info)
}
dscriptions, _ := settingRepo.GetDescriptionList(repo.WithByType("container"))
for i := 0; i < len(records); i++ {
for _, desc := range dscriptions {
if desc.ID == records[i].ContainerID {
records[i].Description = desc.Description
records[i].IsPinned = desc.IsPinned
}
}
}
sort.Slice(records, func(i, j int) bool {
if records[i].IsPinned == records[j].IsPinned {
return list[i].Created > list[j].Created
}
return records[i].IsPinned
})
return records
}

View file

@ -128,6 +128,21 @@ func (u *ImageService) Page(req dto.PageImage) (int64, interface{}, error) {
})
}
imageDescriptions, _ := settingRepo.GetDescriptionList(repo.WithByType("image"))
for i := 0; i < len(list); i++ {
for _, desc := range imageDescriptions {
if "sha256:"+desc.ID == records[i].ID {
records[i].Description = desc.Description
records[i].IsPinned = desc.IsPinned
}
}
}
sort.Slice(records, func(i, j int) bool {
if records[i].IsPinned == records[j].IsPinned {
return records[i].IsUsed
}
return records[i].IsPinned
})
total, start, end := len(records), (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
backDatas = make([]dto.ImageInfo, 0)

View file

@ -7,6 +7,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/ssh"
@ -24,6 +25,8 @@ type ISettingService interface {
GetSystemProxy() (*dto.SystemProxy, error)
GetLocalConn() dto.SSHConnData
GetSettingByKey(key string) string
SaveDescription(req dto.CommonDescription) error
}
func NewISettingService() ISettingService {
@ -170,3 +173,24 @@ func (u *SettingService) GetSettingByKey(key string) string {
return value
}
}
func (u *SettingService) SaveDescription(req dto.CommonDescription) error {
if len(req.Description) == 0 && !req.IsPinned {
_ = settingRepo.DelDescription(req.ID)
return nil
}
data, _ := settingRepo.GetDescription(settingRepo.WithByDescriptionID(req.ID), repo.WithByType(req.Type), repo.WithByDetailType(req.DetailType))
if data.ID == "" {
if err := copier.Copy(&data, &req); err != nil {
return err
}
return settingRepo.CreateDescription(&data)
}
valMap := make(map[string]interface{})
valMap["type"] = req.Type
valMap["detail_type"] = req.DetailType
valMap["is_pinned"] = req.IsPinned
valMap["description"] = req.Description
return settingRepo.UpdateDescription(data.ID, valMap)
}

View file

@ -52,6 +52,7 @@ func InitAgentDB() {
migrations.UpdateCronJob,
migrations.UpdateTensorrtLLM,
migrations.AddIptablesFilterRuleTable,
migrations.AddCommonDescription,
migrations.UpdateDatabase,
})
if err := m.Migrate(); err != nil {

View file

@ -706,6 +706,13 @@ var UpdateTensorrtLLM = &gormigrate.Migration{
},
}
var AddCommonDescription = &gormigrate.Migration{
ID: "20251117-add-common-description",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.CommonDescription{})
},
}
var UpdateDatabase = &gormigrate.Migration{
ID: "20251117-update-database",
Migrate: func(tx *gorm.DB) error {

View file

@ -16,6 +16,8 @@ func (s *SettingRouter) InitRouter(Router *gin.RouterGroup) {
settingRouter.POST("/update", baseApi.UpdateSetting)
settingRouter.GET("/get/:key", baseApi.GetSettingByKey)
settingRouter.POST("/description/save", baseApi.SaveDescription)
settingRouter.GET("/snapshot/load", baseApi.LoadSnapshotData)
settingRouter.POST("/snapshot", baseApi.CreateSnapshot)
settingRouter.POST("/snapshot/recreate", baseApi.RecreateSnapshot)

View file

@ -128,6 +128,13 @@ export namespace Setting {
code: string;
interval: string;
}
export interface CommonDescription {
id: string;
type: string;
detailType: string;
isPinned: boolean;
description: string;
}
export interface SnapshotCreate {
id: number;

View file

@ -71,6 +71,9 @@ export const getAgentSettingInfo = () => {
export const getAgentSettingByKey = (key: string) => {
return http.get<string>(`/settings/get/${key}`);
};
export const updateCommonDescription = (param: Setting.CommonDescription) => {
return http.post(`/settings/description/save`, param);
};
// core
export const getSettingInfo = () => {

View file

@ -80,6 +80,8 @@
:data="data"
@sort-change="search"
@search="search"
@cell-mouse-enter="showFavorite"
@cell-mouse-leave="hideFavorite"
:row-style="{ height: '65px' }"
style="width: 100%"
:columns="columns"
@ -89,27 +91,41 @@
<el-table-column type="selection" />
<el-table-column
:label="$t('commons.table.name')"
:width="mobile ? 300 : 200"
min-width="100"
min-width="250"
prop="name"
sortable
fix
:fixed="mobile ? false : 'left'"
show-overflow-tooltip
>
<template #default="{ row }">
<template #default="{ row, $index }">
<el-text type="primary" class="cursor-pointer" @click="onInspect(row)">
{{ row.name }}
</el-text>
<div class="float-right">
<el-tooltip
:content="row.isPinned ? $t('website.cancelFavorite') : $t('website.favorite')"
v-if="row.isPinned || hoveredRowIndex === $index"
>
<el-button
link
size="large"
:icon="row.isPinned ? 'StarFilled' : 'Star'"
type="warning"
@click="changePinned(row, true)"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column
:label="$t('container.image')"
show-overflow-tooltip
min-width="150"
min-width="180"
prop="imageName"
/>
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="state" sortable>
<el-table-column :label="$t('commons.table.status')" min-width="150" prop="state" sortable>
<template #default="{ row }">
<el-dropdown placement="bottom">
<Status :key="row.state" :status="row.state" :operate="true"></Status>
@ -303,6 +319,20 @@
</div>
</template>
</el-table-column>
<el-table-column
min-width="200"
:label="$t('commons.table.description')"
prop="description"
show-overflow-tooltip
>
<template #default="{ row }">
<fu-input-rw-switch
v-model="row.description"
@enter="changePinned(row, false)"
@blur="changePinned(row, false)"
/>
</template>
</el-table-column>
<el-table-column
:label="$t('container.upTime')"
min-width="200"
@ -367,6 +397,7 @@ import { GlobalStore } from '@/store';
import { routerToName, routerToNameWithQuery } from '@/utils/router';
import router from '@/routers';
import { computeSize2, computeSizeForDocker, computeCPU, newUUID } from '@/utils/util';
import { updateCommonDescription } from '@/api/modules/setting';
const globalStore = GlobalStore();
const mobile = computed(() => {
@ -402,6 +433,8 @@ const taskLogRef = ref();
const tags = ref([]);
const activeTag = ref('all');
const hoveredRowIndex = ref(-1);
const goDashboard = async (port: any) => {
if (port.indexOf('127.0.0.1') !== -1) {
MsgWarning(i18n.global.t('container.unExposedPort'));
@ -474,6 +507,29 @@ const searchWithAppShow = (item: any) => {
search();
};
const showFavorite = (row: any) => {
hoveredRowIndex.value = data.value.findIndex((item) => item === row);
};
const hideFavorite = () => {
hoveredRowIndex.value = -1;
};
const changePinned = (row: any, isPinned: boolean) => {
let params = {
id: row.containerID,
type: 'container',
detailType: '',
isPinned: !row.isPinned,
description: row.description || '',
};
if (isPinned) {
params.isPinned = !row.isPinned;
}
updateCommonDescription(params).then(() => {
search();
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
});
};
const loadContainerCount = async () => {
await loadContainerStatus().then((res) => {
tags.value = [];

View file

@ -36,15 +36,31 @@
:pagination-config="paginationConfig"
:data="data"
@sort-change="search"
@cell-mouse-enter="showFavorite"
@cell-mouse-leave="hideFavorite"
:columns="columns"
@search="search"
:heightDiff="300"
>
<el-table-column label="ID" prop="id" width="140">
<template #default="{ row }">
<el-table-column label="ID" prop="id" width="180">
<template #default="{ row, $index }">
<el-text type="primary" class="cursor-pointer" @click="onInspect(row.id)">
{{ row.id.replaceAll('sha256:', '').substring(0, 12) }}
</el-text>
<div class="float-right">
<el-tooltip
:content="row.isPinned ? $t('website.cancelFavorite') : $t('website.favorite')"
v-if="row.isPinned || hoveredRowIndex === $index"
>
<el-button
link
size="large"
:icon="row.isPinned ? 'StarFilled' : 'Star'"
type="warning"
@click="changePinned(row, true)"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" prop="isUsed" width="100" sortable>
@ -128,6 +144,8 @@ import { searchImage, listImageRepo, imageRemove, inspect, containerPrune } from
import i18n from '@/lang';
import { GlobalStore } from '@/store';
import { ElMessageBox } from 'element-plus';
import { updateCommonDescription } from '@/api/modules/setting';
import { MsgSuccess } from '@/utils/message';
const globalStore = GlobalStore();
const taskLogRef = ref();
@ -155,6 +173,8 @@ const columns = ref([]);
const isActive = ref(false);
const isExist = ref(false);
const hoveredRowIndex = ref(-1);
const myDetail = ref();
const dialogPullRef = ref();
const dialogTagRef = ref();
@ -208,6 +228,29 @@ const onDelete = (row: Container.ImageInfo) => {
});
};
const showFavorite = (row: any) => {
hoveredRowIndex.value = data.value.findIndex((item) => item === row);
};
const hideFavorite = () => {
hoveredRowIndex.value = -1;
};
const changePinned = (row: any, isPinned: boolean) => {
let params = {
id: row.id.replaceAll('sha256:', ''),
type: 'image',
detailType: '',
isPinned: !row.isPinned,
description: row.description || '',
};
if (isPinned) {
params.isPinned = !row.isPinned;
}
updateCommonDescription(params).then(() => {
search();
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
});
};
const onInspect = async (id: string) => {
const res = await inspect({ id: id, type: 'image' });
let detailInfo = JSON.stringify(JSON.parse(res.data), null, 2);