mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2024-11-24 02:59:16 +08:00
feat: 增加已安装应用同步逻辑
This commit is contained in:
parent
5855e9b0d8
commit
69b34e07c9
BIN
apps/icons/halo.png
Normal file
BIN
apps/icons/halo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 66 KiB |
BIN
apps/icons/wordpress.png
Normal file
BIN
apps/icons/wordpress.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"version": "ddd",
|
||||
"tags": [
|
||||
{
|
||||
"key": "WebSite",
|
||||
@ -24,6 +24,8 @@
|
||||
"icon": "mysql.png",
|
||||
"author": "Oracle",
|
||||
"type": "internal",
|
||||
"required": [""],
|
||||
"crossVersionUpdate": false,
|
||||
"source": "https://www.mysql.com"
|
||||
},
|
||||
{
|
||||
@ -35,6 +37,8 @@
|
||||
"icon": "nginx.png",
|
||||
"author": "Nginx",
|
||||
"type": "internal",
|
||||
"required": [""],
|
||||
"crossVersionUpdate": true,
|
||||
"source": "http://nginx.org/"
|
||||
}
|
||||
]
|
||||
|
5
apps/nginx/1.23.1/READEME.md
Normal file
5
apps/nginx/1.23.1/READEME.md
Normal file
@ -0,0 +1,5 @@
|
||||
# What is nginx?
|
||||
- - -
|
||||
Nginx (pronounced "engine-x") is an open source reverse proxy server for HTTP, HTTPS, SMTP, POP3, and IMAP protocols, as well as a load balancer, HTTP cache, and a web server (origin server). The nginx project started with a strong focus on high concurrency, high performance and low memory usage. It is licensed under the 2-clause BSD-like license and it runs on Linux, BSD variants, Mac OS X, Solaris, AIX, HP-UX, as well as on other *nix flavors. It also has a proof of concept port for Microsoft Windows.
|
||||
|
||||
[wikipedia.org/wiki/Nginx](http://wikipedia.org/wiki/Nginx)
|
22
apps/wordpress/6.0.1/docker-compose.yml
Normal file
22
apps/wordpress/6.0.1/docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
||||
version: '3'
|
||||
services:
|
||||
1panel_wordpress:
|
||||
image: wordpress:6.0.1
|
||||
container_name: 1panel_wordpress
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: always
|
||||
networks:
|
||||
- 1panel
|
||||
volumes:
|
||||
- ./data:/var/www/html
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: 1panel_mysql
|
||||
WORDPRESS_DB_NAME: wpdb
|
||||
WORDPRESS_DB_USER: root
|
||||
WORDPRESS_DB_PASSWORD: Password@123
|
||||
WORDPRESS_DEBUG: 1
|
||||
|
||||
networks:
|
||||
1panel:
|
||||
external: true
|
@ -25,7 +25,7 @@ func (b *BaseApi) AppSearch(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (b *BaseApi) AppSync(c *gin.Context) {
|
||||
if err := appService.Sync(); err != nil {
|
||||
if err := appService.SyncAppList(); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
@ -106,3 +106,11 @@ func (b *BaseApi) InstallOperate(c *gin.Context) {
|
||||
|
||||
helper.SuccessWithData(c, nil)
|
||||
}
|
||||
|
||||
func (b *BaseApi) InstalledSync(c *gin.Context) {
|
||||
if err := appService.SyncAllInstalled(); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, "")
|
||||
}
|
||||
|
@ -88,9 +88,18 @@ var (
|
||||
Down AppOperate = "down"
|
||||
Restart AppOperate = "restart"
|
||||
Delete AppOperate = "delete"
|
||||
Sync AppOperate = "sync"
|
||||
)
|
||||
|
||||
type AppInstallOperate struct {
|
||||
InstallId uint `json:"installId" validate:"required"`
|
||||
Operate AppOperate `json:"operate" validate:"required"`
|
||||
}
|
||||
|
||||
//type AppContainer struct {
|
||||
// Names []string `json:"names"`
|
||||
// Image string `json:"image"`
|
||||
// Ports string `json:"ports"`
|
||||
// Status string `json:"status"`
|
||||
// State string `json:"state"`
|
||||
//}
|
||||
|
9
backend/app/model/app_container.go
Normal file
9
backend/app/model/app_container.go
Normal file
@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type AppContainer struct {
|
||||
BaseModel
|
||||
ServiceName string `json:"serviceName"`
|
||||
ContainerName string `json:"containerName"`
|
||||
AppInstallId uint `json:"appInstallId"`
|
||||
Image string `json:"image"`
|
||||
}
|
@ -1,15 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/global"
|
||||
"path"
|
||||
)
|
||||
|
||||
type AppInstall struct {
|
||||
BaseModel
|
||||
Name string `json:"name" gorm:"type:varchar(64);not null"`
|
||||
ContainerName string `json:"containerName" gorm:"type:varchar(256);not null"`
|
||||
Version string `json:"version" gorm:"type:varchar(256);not null"`
|
||||
AppId uint `json:"appId" gorm:"type:integer;not null"`
|
||||
AppDetailId uint `json:"appDetailId" gorm:"type:integer;not null"`
|
||||
Params string `json:"params" gorm:"type:longtext;not null"`
|
||||
Status string `json:"status" gorm:"type:varchar(256);not null"`
|
||||
Description string `json:"description" gorm:"type:varchar(256);not null"`
|
||||
Message string `json:"message" gorm:"type:longtext;not null"`
|
||||
App App `json:"-"`
|
||||
Name string `json:"name" gorm:"type:varchar(64);not null"`
|
||||
Version string `json:"version" gorm:"type:varchar(256);not null"`
|
||||
AppId uint `json:"appId" gorm:"type:integer;not null"`
|
||||
AppDetailId uint `json:"appDetailId" gorm:"type:integer;not null"`
|
||||
Params string `json:"params" gorm:"type:longtext;not null"`
|
||||
Status string `json:"status" gorm:"type:varchar(256);not null"`
|
||||
Description string `json:"description" gorm:"type:varchar(256);not null"`
|
||||
Message string `json:"message" gorm:"type:longtext;not null"`
|
||||
App App `json:"-"`
|
||||
Containers []AppContainer `json:"containers"`
|
||||
}
|
||||
|
||||
func (i AppInstall) GetPath() string {
|
||||
return path.Join(global.CONF.System.AppDir, i.App.Key, i.Name)
|
||||
}
|
||||
|
||||
func (i AppInstall) GetComposePath() string {
|
||||
return path.Join(global.CONF.System.AppDir, i.App.Key, i.Name, "docker-compose.yml")
|
||||
}
|
||||
|
21
backend/app/repo/app_container.go
Normal file
21
backend/app/repo/app_container.go
Normal file
@ -0,0 +1,21 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/1Panel-dev/1Panel/app/model"
|
||||
"github.com/1Panel-dev/1Panel/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AppContainerRepo struct {
|
||||
}
|
||||
|
||||
func (a AppContainerRepo) Create(container *model.AppContainer) error {
|
||||
db := global.DB.Model(&model.AppContainer{})
|
||||
return db.Create(&container).Error
|
||||
}
|
||||
|
||||
func (a AppContainerRepo) BatchCreate(ctx context.Context, containers []*model.AppContainer) error {
|
||||
db := ctx.Value("db").(*gorm.DB)
|
||||
return db.Model(&model.AppContainer{}).Create(&containers).Error
|
||||
}
|
@ -13,7 +13,17 @@ func (a AppInstallRepo) GetBy(opts ...DBOption) ([]model.AppInstall, error) {
|
||||
db = opt(db)
|
||||
}
|
||||
var install []model.AppInstall
|
||||
err := db.Preload("App").Find(&install).Error
|
||||
err := db.Preload("App").Preload("Containers").Find(&install).Error
|
||||
return install, err
|
||||
}
|
||||
|
||||
func (a AppInstallRepo) GetFirst(opts ...DBOption) (model.AppInstall, error) {
|
||||
db := global.DB.Model(&model.AppInstall{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
var install model.AppInstall
|
||||
err := db.Preload("App").Preload("Containers").First(&install).Error
|
||||
return install, err
|
||||
}
|
||||
|
||||
@ -43,6 +53,6 @@ func (a AppInstallRepo) Page(page, size int, opts ...DBOption) (int64, []model.A
|
||||
}
|
||||
count := int64(0)
|
||||
db = db.Count(&count)
|
||||
err := db.Debug().Limit(size).Offset(size * (page - 1)).Preload("App").Find(&apps).Error
|
||||
err := db.Debug().Limit(size).Offset(size * (page - 1)).Preload("App").Preload("Containers").Find(&apps).Error
|
||||
return count, apps, err
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ type RepoGroup struct {
|
||||
TagRepo
|
||||
AppDetailRepo
|
||||
AppInstallRepo
|
||||
AppContainerRepo
|
||||
}
|
||||
|
||||
var RepoGroupApp = new(RepoGroup)
|
||||
|
@ -11,14 +11,17 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/global"
|
||||
"github.com/1Panel-dev/1Panel/utils/common"
|
||||
"github.com/1Panel-dev/1Panel/utils/compose"
|
||||
"github.com/1Panel-dev/1Panel/utils/docker"
|
||||
"github.com/1Panel-dev/1Panel/utils/files"
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/net/context"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AppService struct {
|
||||
@ -158,7 +161,8 @@ func (a AppService) Operate(req dto.AppInstallOperate) error {
|
||||
}
|
||||
|
||||
install := appInstall[0]
|
||||
dockerComposePath := path.Join(global.CONF.System.AppDir, install.App.Key, install.ContainerName, "docker-compose.yml")
|
||||
dockerComposePath := install.GetComposePath()
|
||||
|
||||
switch req.Operate {
|
||||
case dto.Up:
|
||||
out, err := compose.Up(dockerComposePath)
|
||||
@ -180,7 +184,7 @@ func (a AppService) Operate(req dto.AppInstallOperate) error {
|
||||
install.Status = constant.Running
|
||||
case dto.Delete:
|
||||
op := files.NewFileOp()
|
||||
appDir := path.Join(global.CONF.System.AppDir, install.App.Key, install.ContainerName)
|
||||
appDir := install.GetPath()
|
||||
dir, _ := os.Stat(appDir)
|
||||
if dir == nil {
|
||||
return appInstallRepo.Delete(commonRepo.WithByID(install.ID))
|
||||
@ -196,6 +200,11 @@ func (a AppService) Operate(req dto.AppInstallOperate) error {
|
||||
_ = op.DeleteDir(appDir)
|
||||
_ = appInstallRepo.Delete(commonRepo.WithByID(install.ID))
|
||||
return nil
|
||||
case dto.Sync:
|
||||
if err := a.SyncInstalled(install.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("operate not support")
|
||||
}
|
||||
@ -233,30 +242,28 @@ func (a AppService) Install(name string, appDetailId uint, params map[string]int
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
containerName := constant.ContainerPrefix + app.Key + "-" + common.RandStr(6)
|
||||
appInstall := model.AppInstall{
|
||||
Name: name,
|
||||
AppId: appDetail.AppId,
|
||||
AppDetailId: appDetail.ID,
|
||||
Version: appDetail.Version,
|
||||
Status: constant.Installing,
|
||||
Params: string(paramByte),
|
||||
ContainerName: containerName,
|
||||
Message: "",
|
||||
Name: name,
|
||||
AppId: appDetail.AppId,
|
||||
AppDetailId: appDetail.ID,
|
||||
Version: appDetail.Version,
|
||||
Status: constant.Installing,
|
||||
Params: string(paramByte),
|
||||
}
|
||||
|
||||
resourceDir := path.Join(global.CONF.System.ResourceDir, "apps", app.Key, appDetail.Version)
|
||||
installDir := path.Join(global.CONF.System.AppDir, app.Key)
|
||||
installAppDir := path.Join(installDir, appDetail.Version)
|
||||
op := files.NewFileOp()
|
||||
if err := op.Copy(resourceDir, installAppDir); err != nil {
|
||||
installVersionDir := path.Join(installDir, appDetail.Version)
|
||||
fileOp := files.NewFileOp()
|
||||
if err := fileOp.Copy(resourceDir, installVersionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
containerNameDir := path.Join(installDir, containerName)
|
||||
if err := op.Rename(installAppDir, containerNameDir); err != nil {
|
||||
appDir := path.Join(installDir, name)
|
||||
if err := fileOp.Rename(installVersionDir, appDir); err != nil {
|
||||
return err
|
||||
}
|
||||
composeFilePath := path.Join(containerNameDir, "docker-compose.yml")
|
||||
envPath := path.Join(containerNameDir, ".env")
|
||||
composeFilePath := path.Join(appDir, "docker-compose.yml")
|
||||
envPath := path.Join(appDir, ".env")
|
||||
|
||||
envParams := make(map[string]string, len(params))
|
||||
for k, v := range params {
|
||||
@ -269,13 +276,58 @@ func (a AppService) Install(name string, appDetailId uint, params map[string]int
|
||||
envParams[k] = t.(string)
|
||||
}
|
||||
}
|
||||
envParams["CONTAINER_NAME"] = containerName
|
||||
if err := godotenv.Write(envParams, envPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileContent, err := os.ReadFile(composeFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composeMap := make(map[string]interface{})
|
||||
if err := yaml.Unmarshal(fileContent, &composeMap); err != nil {
|
||||
return err
|
||||
}
|
||||
servicesMap := composeMap["services"].(map[string]interface{})
|
||||
changeKeys := make(map[string]string, len(servicesMap))
|
||||
var appContainers []*model.AppContainer
|
||||
for k, v := range servicesMap {
|
||||
serviceName := k + "-" + common.RandStr(4)
|
||||
changeKeys[k] = serviceName
|
||||
value := v.(map[string]interface{})
|
||||
containerName := constant.ContainerPrefix + k + "-" + common.RandStr(4)
|
||||
value["container_name"] = containerName
|
||||
var image string
|
||||
if i, ok := value["image"]; ok {
|
||||
image = i.(string)
|
||||
}
|
||||
appContainers = append(appContainers, &model.AppContainer{
|
||||
ServiceName: serviceName,
|
||||
ContainerName: containerName,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
for k, v := range changeKeys {
|
||||
servicesMap[v] = servicesMap[k]
|
||||
delete(servicesMap, k)
|
||||
}
|
||||
serviceByte, err := yaml.Marshal(servicesMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.WriteFile(composeFilePath, strings.NewReader(string(serviceByte)), 0775); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := appInstallRepo.Create(&appInstall); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range appContainers {
|
||||
c.AppInstallId = appInstall.ID
|
||||
}
|
||||
if err := appContainerRepo.BatchCreate(context.WithValue(context.Background(), "db", global.DB), appContainers); err != nil {
|
||||
return err
|
||||
}
|
||||
go upApp(composeFilePath, appInstall)
|
||||
return nil
|
||||
}
|
||||
@ -296,12 +348,96 @@ func upApp(composeFilePath string, appInstall model.AppInstall) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a AppService) SyncInstalled() error {
|
||||
|
||||
func (a AppService) SyncAllInstalled() error {
|
||||
allList, err := appInstallRepo.GetBy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
for _, i := range allList {
|
||||
if err := a.SyncInstalled(i.ID); err != nil {
|
||||
global.LOG.Errorf("sync install app[%s] error,mgs: %s", i.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AppService) Sync() error {
|
||||
func (a AppService) SyncInstalled(installId uint) error {
|
||||
appInstall, err := appInstallRepo.GetFirst(commonRepo.WithByID(installId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var containerNames []string
|
||||
for _, a := range appInstall.Containers {
|
||||
containerNames = append(containerNames, a.ContainerName)
|
||||
}
|
||||
cli, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
containers, err := cli.ListContainersByName(containerNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errorContainers []string
|
||||
var notFoundContainers []string
|
||||
|
||||
for _, n := range containers {
|
||||
if n.State != "running" {
|
||||
errorContainers = append(errorContainers, n.Names...)
|
||||
}
|
||||
}
|
||||
for _, old := range containerNames {
|
||||
exist := false
|
||||
for _, new := range containers {
|
||||
if common.ExistWithStrArray(old, new.Names) {
|
||||
exist = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exist {
|
||||
notFoundContainers = append(notFoundContainers, old)
|
||||
}
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
appInstall.Status = constant.Error
|
||||
appInstall.Message = "container is not found"
|
||||
return appInstallRepo.Save(appInstall)
|
||||
}
|
||||
|
||||
if len(errorContainers) == 0 && len(notFoundContainers) == 0 {
|
||||
appInstall.Status = constant.Running
|
||||
return appInstallRepo.Save(appInstall)
|
||||
}
|
||||
if len(errorContainers) == len(containerNames) {
|
||||
appInstall.Status = constant.Error
|
||||
}
|
||||
if len(notFoundContainers) == len(containerNames) {
|
||||
appInstall.Status = constant.Stopped
|
||||
}
|
||||
|
||||
var errMsg strings.Builder
|
||||
if len(errorContainers) > 0 {
|
||||
errMsg.Write([]byte(string(rune(len(errorContainers))) + " error containers:"))
|
||||
for _, e := range errorContainers {
|
||||
errMsg.Write([]byte(e))
|
||||
}
|
||||
errMsg.Write([]byte("\n"))
|
||||
}
|
||||
if len(notFoundContainers) > 0 {
|
||||
errMsg.Write([]byte(string(rune(len(notFoundContainers))) + " not found containers:"))
|
||||
for _, e := range notFoundContainers {
|
||||
errMsg.Write([]byte(e))
|
||||
}
|
||||
errMsg.Write([]byte("\n"))
|
||||
}
|
||||
appInstall.Message = errMsg.String()
|
||||
return appInstallRepo.Save(appInstall)
|
||||
}
|
||||
|
||||
func (a AppService) SyncAppList() error {
|
||||
//TODO 从 oss 拉取最新列表
|
||||
var appConfig model.AppConfig
|
||||
appConfig.OssPath = global.CONF.System.AppOss
|
||||
|
@ -30,5 +30,6 @@ var (
|
||||
appTagRepo = repo.RepoGroupApp.AppTagRepo
|
||||
appDetailRepo = repo.RepoGroupApp.AppDetailRepo
|
||||
tagRepo = repo.RepoGroupApp.TagRepo
|
||||
appInstallRepo = repo.AppInstallRepo{}
|
||||
appInstallRepo = repo.RepoGroupApp.AppInstallRepo
|
||||
appContainerRepo = repo.RepoGroupApp.AppContainerRepo
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ package constant
|
||||
|
||||
const (
|
||||
Running = "Running"
|
||||
Warning = "Warning"
|
||||
UnHealthy = "UnHealthy"
|
||||
Error = "Error"
|
||||
Stopped = "Stopped"
|
||||
Installing = "Installing"
|
||||
|
@ -50,12 +50,17 @@ require (
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
||||
github.com/docker/distribution v2.8.1+incompatible // indirect
|
||||
github.com/docker/docker v20.10.18+incompatible // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
@ -100,6 +105,8 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
@ -118,9 +125,10 @@ require (
|
||||
go.opentelemetry.io/otel v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.0.0 // indirect
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect
|
||||
golang.org/x/tools v0.1.10 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/ini.v1 v1.66.6 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
|
@ -41,6 +41,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
||||
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
@ -85,6 +87,14 @@ github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/Lu
|
||||
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68=
|
||||
github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v20.10.18+incompatible h1:SN84VYXTBNGn92T/QwIRPlum9zfemfitN7pbsp26WSc=
|
||||
github.com/docker/docker v20.10.18+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
|
||||
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
@ -358,6 +368,10 @@ github.com/nwaples/rardecode/v2 v2.0.0-beta.2 h1:e3mzJFJs4k83GXBEiTaQ5HgSc/kOK8q
|
||||
github.com/nwaples/rardecode/v2 v2.0.0-beta.2/go.mod h1:yntwv/HfMc/Hbvtq9I19D1n58te3h6KsqCf3GxyfBGY=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
|
||||
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
|
||||
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
@ -528,6 +542,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -711,6 +727,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
@ -150,6 +150,6 @@ var AddTableCronjob = &gormigrate.Migration{
|
||||
var AddTableApp = &gormigrate.Migration{
|
||||
ID: "20200921-add-table-app",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.Tag{}, &model.AppTag{}, &model.AppConfig{}, &model.AppInstall{})
|
||||
return tx.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.Tag{}, &model.AppTag{}, &model.AppConfig{}, &model.AppInstall{}, &model.AppContainer{}, &model.AppContainer{})
|
||||
},
|
||||
}
|
||||
|
@ -22,5 +22,6 @@ func (a *AppRouter) InitAppRouter(Router *gin.RouterGroup) {
|
||||
appRouter.POST("/install", baseApi.InstallApp)
|
||||
appRouter.POST("/installed", baseApi.PageInstalled)
|
||||
appRouter.POST("/installed/op", baseApi.InstallOperate)
|
||||
appRouter.POST("/installed/sync", baseApi.InstalledSync)
|
||||
}
|
||||
}
|
||||
|
@ -71,3 +71,12 @@ func ScanPort(port string) bool {
|
||||
defer ln.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
func ExistWithStrArray(str string, arr []string) bool {
|
||||
for _, a := range arr {
|
||||
if strings.Contains(a, str) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
49
backend/utils/docker/docker.go
Normal file
49
backend/utils/docker/docker.go
Normal file
@ -0,0 +1,49 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
func NewClient() (Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
|
||||
return Client{
|
||||
cli: cli,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c Client) ListAllContainers() ([]types.Container, error) {
|
||||
var options types.ContainerListOptions
|
||||
containers, err := c.cli.ContainerList(context.Background(), options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (c Client) ListContainersByName(names []string) ([]types.Container, error) {
|
||||
var options types.ContainerListOptions
|
||||
options.All = true
|
||||
if len(names) > 0 {
|
||||
var array []filters.KeyValuePair
|
||||
for _, n := range names {
|
||||
array = append(array, filters.Arg("name", n))
|
||||
}
|
||||
options.Filters = filters.NewArgs(array...)
|
||||
}
|
||||
containers, err := c.cli.ContainerList(context.Background(), options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return containers, nil
|
||||
}
|
@ -29,6 +29,10 @@ func NewFileOp() FileOp {
|
||||
}
|
||||
}
|
||||
|
||||
func (f FileOp) OpenFile(dst string) (fs.File, error) {
|
||||
return f.Fs.Open(dst)
|
||||
}
|
||||
|
||||
func (f FileOp) CreateDir(dst string, mode fs.FileMode) error {
|
||||
return f.Fs.MkdirAll(dst, mode)
|
||||
}
|
||||
|
@ -63,7 +63,6 @@ export namespace App {
|
||||
|
||||
export interface AppInstalled extends CommonModel {
|
||||
name: string;
|
||||
containerName: string;
|
||||
version: string;
|
||||
appId: string;
|
||||
appDetailId: string;
|
||||
@ -72,9 +71,8 @@ export namespace App {
|
||||
description: string;
|
||||
message: string;
|
||||
appName: string;
|
||||
total: number;
|
||||
ready: number;
|
||||
icon: string;
|
||||
constainers: any[];
|
||||
}
|
||||
|
||||
export interface AppInstalledOp {
|
||||
|
@ -29,3 +29,7 @@ export const GetAppInstalled = (info: ReqPage) => {
|
||||
export const InstalledOp = (op: App.AppInstalledOp) => {
|
||||
return http.post<any>('apps/installed/op', op);
|
||||
};
|
||||
|
||||
export const SyncInstalledApp = () => {
|
||||
return http.post<any>('apps/installed/sync', {});
|
||||
};
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 66 KiB |
@ -408,5 +408,6 @@ export default {
|
||||
description: '描述',
|
||||
delete: '删除',
|
||||
deleteWarn: '删除操作会把数据一并删除,此操作不可回滚,是否继续?',
|
||||
syncSuccess: '同步成功',
|
||||
},
|
||||
};
|
||||
|
@ -1,53 +1,60 @@
|
||||
<template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-input v-model="req.name" @blur="searchByName"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="req.tags" multiple style="width: 100%" @change="changeTag">
|
||||
<el-option v-for="item in tags" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col v-for="(app, index) in apps" :key="index" :xs="8" :sm="8" :lg="4">
|
||||
<div @click="getAppDetail(app.id)">
|
||||
<el-card :body-style="{ padding: '0px' }" class="a-card">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<div class="icon">
|
||||
<el-image class="image" :src="'data:image/png;base64,' + app.icon"></el-image>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<div class="a-detail">
|
||||
<div class="d-name">
|
||||
<font size="3" style="font-weight: 700">{{ app.name }}</font>
|
||||
<div v-loading="loading">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-input v-model="req.name" @blur="searchByName"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-select v-model="req.tags" multiple style="width: 100%" @change="changeTag">
|
||||
<el-option v-for="item in tags" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
<el-button @click="sync">{{ $t('app.sync') }}</el-button>
|
||||
</el-col>
|
||||
<el-col v-for="(app, index) in apps" :key="index" :xs="8" :sm="8" :lg="4">
|
||||
<div @click="getAppDetail(app.id)">
|
||||
<el-card :body-style="{ padding: '0px' }" class="a-card">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<div class="icon">
|
||||
<el-image class="image" :src="'data:image/png;base64,' + app.icon"></el-image>
|
||||
</div>
|
||||
<div class="d-description">
|
||||
<font size="1">
|
||||
<span>
|
||||
{{ app.shortDesc }}
|
||||
</span>
|
||||
</font>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<div class="a-detail">
|
||||
<div class="d-name">
|
||||
<font size="3" style="font-weight: 700">{{ app.name }}</font>
|
||||
</div>
|
||||
<div class="d-description">
|
||||
<font size="1">
|
||||
<span>
|
||||
{{ app.shortDesc }}
|
||||
</span>
|
||||
</font>
|
||||
</div>
|
||||
<div class="d-tag">
|
||||
<el-tag v-for="(tag, ind) in app.tags" :key="ind" round :colr="getColor(ind)">
|
||||
{{ tag.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-tag">
|
||||
<el-tag v-for="(tag, ind) in app.tags" :key="ind" round :colr="getColor(ind)">
|
||||
{{ tag.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { App } from '@/api/interface/app';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import router from '@/routers';
|
||||
import { SearchApp } from '@/api/modules/app';
|
||||
import { SearchApp, SyncApp } from '@/api/modules/app';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
|
||||
let req = reactive({
|
||||
name: '',
|
||||
@ -59,6 +66,7 @@ let req = reactive({
|
||||
let apps = ref<App.App[]>([]);
|
||||
let tags = ref<App.Tag[]>([]);
|
||||
const colorArr = ['#6495ED', '#54FF9F', '#BEBEBE', '#FFF68F', '#FFFF00', '#8B0000'];
|
||||
let loading = ref(false);
|
||||
|
||||
const getColor = (index: number) => {
|
||||
return colorArr[index];
|
||||
@ -78,6 +86,17 @@ const getAppDetail = (id: number) => {
|
||||
router.push({ name: 'AppDetail', params });
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
loading.value = true;
|
||||
SyncApp()
|
||||
.then(() => {
|
||||
ElMessage.success(i18n.global.t('app.syncSuccess'));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const changeTag = () => {
|
||||
search(req);
|
||||
};
|
||||
|
@ -21,25 +21,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { SyncApp } from '@/api/modules/app';
|
||||
import LayoutContent from '@/layout/layout-content.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const activeName = ref('all');
|
||||
|
||||
const sync = () => {
|
||||
SyncApp().then((res) => {
|
||||
console.log(res);
|
||||
});
|
||||
};
|
||||
|
||||
const routerTo = (path: string) => {
|
||||
router.push({ path: path });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
sync();
|
||||
const path = router.currentRoute.value.path;
|
||||
if (path === '/apps/all') {
|
||||
activeName.value = 'all';
|
||||
|
@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<div style="float: right; margin-bottom: 5px">
|
||||
<el-button @click="sync">{{ $t('app.sync') }}</el-button>
|
||||
</div>
|
||||
<ComplexTable :pagination-config="paginationConfig" :data="data" @search="search" v-loading="loading">
|
||||
<el-table-column :label="$t('app.name')" prop="name"></el-table-column>
|
||||
<!-- <el-table-column :label="$t('app.description')" prop="description"></el-table-column> -->
|
||||
<el-table-column :label="$t('app.appName')" prop="appName"></el-table-column>
|
||||
<el-table-column :label="$t('app.version')" prop="version"></el-table-column>
|
||||
<el-table-column :label="$t('app.container')">
|
||||
<template #default="{ row }">
|
||||
{{ row.ready / row.total }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('app.status')">
|
||||
<template #default="{ row }">
|
||||
<el-popover
|
||||
@ -32,7 +30,7 @@
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<fu-table-operations
|
||||
width="200px"
|
||||
width="250px"
|
||||
:ellipsis="10"
|
||||
:buttons="buttons"
|
||||
:label="$t('commons.table.operate')"
|
||||
@ -52,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { GetAppInstalled, InstalledOp } from '@/api/modules/app';
|
||||
import { GetAppInstalled, InstalledOp, SyncInstalledApp } from '@/api/modules/app';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import ComplexTable from '@/components/complex-table/index.vue';
|
||||
import { dateFromat } from '@/utils/util';
|
||||
@ -72,6 +70,18 @@ let operateReq = reactive({
|
||||
operate: '',
|
||||
});
|
||||
|
||||
const sync = () => {
|
||||
loading.value = true;
|
||||
SyncInstalledApp()
|
||||
.then(() => {
|
||||
ElMessage.success(i18n.global.t('app.syncSuccess'));
|
||||
search();
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const search = () => {
|
||||
const req = {
|
||||
page: paginationConfig.currentPage,
|
||||
@ -122,12 +132,21 @@ const getMsg = (op: string) => {
|
||||
case 'delete':
|
||||
tip = i18n.global.t('app.deleteWarn');
|
||||
break;
|
||||
case 'sync':
|
||||
tip = i18n.global.t('app.sync');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return tip;
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: i18n.global.t('app.sync'),
|
||||
click: (row: any) => {
|
||||
openOperate(row, 'sync');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('app.restart'),
|
||||
click: (row: any) => {
|
||||
|
Loading…
Reference in New Issue
Block a user