1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-01-19 00:09:16 +08:00

feat(task): Add init taskDB (#7572)

* feat(task): Add init taskDB

* feat(task): Optimize Container Log Reading
This commit is contained in:
zhengkunwang 2024-12-26 18:32:40 +08:00 committed by GitHub
parent 8fe40b7a97
commit e4a191c092
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 319 additions and 516 deletions

View File

@ -5,7 +5,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -470,34 +469,6 @@ func (b *BaseApi) Inspect(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags Container
// @Summary Container logs
// @Description 容器日志
// @Param container query string false "容器名称"
// @Param since query string false "时间筛选"
// @Param follow query string false "是否追踪"
// @Param tail query string false "显示行号"
// @Security ApiKeyAuth
// @Router /containers/search/log [post]
func (b *BaseApi) ContainerLogs(c *gin.Context) {
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
global.LOG.Errorf("gin context http handler failed, err: %v", err)
return
}
defer wsConn.Close()
container := c.Query("container")
since := c.Query("since")
follow := c.Query("follow") == "true"
tail := c.Query("tail")
if err := containerService.ContainerLogs(wsConn, "container", container, since, tail, follow); err != nil {
_ = wsConn.WriteMessage(1, []byte(err.Error()))
return
}
}
// @Description 下载容器日志 // @Description 下载容器日志
// @Router /containers/download/log [post] // @Router /containers/download/log [post]
func (b *BaseApi) DownloadContainerLogs(c *gin.Context) { func (b *BaseApi) DownloadContainerLogs(c *gin.Context) {
@ -707,30 +678,38 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags Container Compose // @Tags Container
// @Summary Container Compose logs // @Summary Container logs
// @Description docker-compose 日志 // @Description 容器日志
// @Param compose query string false "compose 文件地址" // @Param container query string false "容器名称"
// @Param since query string false "时间筛选" // @Param since query string false "时间筛选"
// @Param follow query string false "是否追踪" // @Param follow query string false "是否追踪"
// @Param tail query string false "显示行号" // @Param tail query string false "显示行号"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Router /containers/compose/search/log [get] // @Router /containers/search/log [post]
func (b *BaseApi) ComposeLogs(c *gin.Context) { func (b *BaseApi) ContainerStreamLogs(c *gin.Context) {
wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil) c.Header("Content-Type", "text/event-stream")
if err != nil { c.Header("Cache-Control", "no-cache")
global.LOG.Errorf("gin context http handler failed, err: %v", err) c.Header("Connection", "keep-alive")
return c.Header("Transfer-Encoding", "chunked")
}
defer wsConn.Close()
compose := c.Query("compose")
since := c.Query("since") since := c.Query("since")
follow := c.Query("follow") == "true" follow := c.Query("follow") == "true"
tail := c.Query("tail") tail := c.Query("tail")
if err := containerService.ContainerLogs(wsConn, "compose", compose, since, tail, follow); err != nil { container := c.Query("container")
_ = wsConn.WriteMessage(1, []byte(err.Error())) compose := c.Query("compose")
return streamLog := dto.StreamLog{
Compose: compose,
Container: container,
Since: since,
Follow: follow,
Tail: tail,
Type: "container",
} }
if compose != "" {
streamLog.Type = "compose"
}
containerService.StreamLogs(c, streamLog)
} }

View File

@ -268,3 +268,12 @@ type ContainerLog struct {
Tail uint `json:"tail"` Tail uint `json:"tail"`
ContainerType string `json:"containerType"` ContainerType string `json:"containerType"`
} }
type StreamLog struct {
Compose string
Container string
Since string
Follow bool
Tail string
Type string
}

View File

@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@ -19,9 +20,6 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -44,7 +42,6 @@ import (
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/gorilla/websocket"
v1 "github.com/opencontainers/image-spec/specs-go/v1" v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
@ -74,7 +71,6 @@ type IContainerService interface {
ContainerCommit(req dto.ContainerCommit) error ContainerCommit(req dto.ContainerCommit) error
ContainerLogClean(req dto.OperationWithName) error ContainerLogClean(req dto.OperationWithName) error
ContainerOperation(req dto.ContainerOperation) error ContainerOperation(req dto.ContainerOperation) error
ContainerLogs(wsConn *websocket.Conn, containerType, container, since, tail string, follow bool) error
DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error
ContainerStats(id string) (*dto.ContainerStats, error) ContainerStats(id string) (*dto.ContainerStats, error)
Inspect(req dto.InspectReq) (string, error) Inspect(req dto.InspectReq) (string, error)
@ -87,6 +83,8 @@ type IContainerService interface {
Prune(req dto.ContainerPrune) (dto.ContainerPruneReport, error) Prune(req dto.ContainerPrune) (dto.ContainerPruneReport, error)
LoadContainerLogs(req dto.OperationWithNameAndType) string LoadContainerLogs(req dto.OperationWithNameAndType) string
StreamLogs(ctx *gin.Context, params dto.StreamLog)
} }
func NewIContainerService() IContainerService { func NewIContainerService() IContainerService {
@ -794,87 +792,87 @@ func (u *ContainerService) ContainerLogClean(req dto.OperationWithName) error {
return nil return nil
} }
func (u *ContainerService) ContainerLogs(wsConn *websocket.Conn, containerType, container, since, tail string, follow bool) error { func (u *ContainerService) StreamLogs(ctx *gin.Context, params dto.StreamLog) {
defer func() { wsConn.Close() }() messageChan := make(chan string, 1024)
if cmd.CheckIllegal(container, since, tail) { errorChan := make(chan error, 1)
return buserr.New(constant.ErrCmdIllegal)
} go collectLogs(params, messageChan, errorChan)
commandName := "docker"
commandArg := []string{"logs", container} ctx.Stream(func(w io.Writer) bool {
if containerType == "compose" { select {
commandArg = []string{"compose", "-f", container, "logs"} case msg, ok := <-messageChan:
} if !ok {
if tail != "0" { return false
commandArg = append(commandArg, "--tail") }
commandArg = append(commandArg, tail) _, err := fmt.Fprintf(w, "data: %v\n\n", msg)
} if err != nil {
if since != "all" { return false
commandArg = append(commandArg, "--since") }
commandArg = append(commandArg, since) return true
} case err := <-errorChan:
if follow { _, err = fmt.Fprintf(w, "data: {\"event\": \"error\", \"data\": \"%s\"}\n\n", err.Error())
commandArg = append(commandArg, "-f") if err != nil {
} return false
if !follow { }
cmd := exec.Command(commandName, commandArg...) return false
cmd.Stderr = cmd.Stdout case <-ctx.Request.Context().Done():
stdout, _ := cmd.CombinedOutput() return false
if !utf8.Valid(stdout) { }
return errors.New("invalid utf8") })
} }
if err := wsConn.WriteMessage(websocket.TextMessage, stdout); err != nil {
global.LOG.Errorf("send message with log to ws failed, err: %v", err) func collectLogs(params dto.StreamLog, messageChan chan<- string, errorChan chan<- error) {
} defer close(messageChan)
return nil defer close(errorChan)
}
var cmdArgs []string
if params.Type == "compose" {
cmdArgs = []string{"compose", "-f", params.Compose}
}
cmdArgs = append(cmdArgs, "logs")
if params.Follow {
cmdArgs = append(cmdArgs, "-f")
}
if params.Tail != "all" {
cmdArgs = append(cmdArgs, "--tail", params.Tail)
}
if params.Since != "all" {
cmdArgs = append(cmdArgs, "--since", params.Since)
}
if params.Container != "" {
cmdArgs = append(cmdArgs, params.Container)
}
cmd := exec.Command("docker", cmdArgs...)
cmd := exec.Command(commandName, commandArg...)
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
_ = cmd.Process.Signal(syscall.SIGTERM) errorChan <- fmt.Errorf("failed to get stdout pipe: %v", err)
return err return
} }
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
_ = cmd.Process.Signal(syscall.SIGTERM) errorChan <- fmt.Errorf("failed to start command: %v", err)
return err return
} }
exitCh := make(chan struct{})
go func() {
_, wsData, _ := wsConn.ReadMessage()
if string(wsData) == "close conn" {
_ = cmd.Process.Signal(syscall.SIGTERM)
exitCh <- struct{}{}
}
}()
go func() { scanner := bufio.NewScanner(stdout)
buffer := make([]byte, 1024) lineNumber := 0
for {
select { for scanner.Scan() {
case <-exitCh: lineNumber++
return message := scanner.Text()
default: select {
n, err := stdout.Read(buffer) case messageChan <- message:
if err != nil { case <-time.After(time.Second):
if err == io.EOF { errorChan <- fmt.Errorf("message channel blocked")
return return
}
global.LOG.Errorf("read bytes from log failed, err: %v", err)
return
}
if !utf8.Valid(buffer[:n]) {
continue
}
if err = wsConn.WriteMessage(websocket.TextMessage, buffer[:n]); err != nil {
global.LOG.Errorf("send message with log to ws failed, err: %v", err)
return
}
}
} }
}() }
_ = cmd.Wait()
return nil if err := scanner.Err(); err != nil {
errorChan <- fmt.Errorf("scanner error: %v", err)
return
}
cmd.Wait()
} }
func (u *ContainerService) DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error { func (u *ContainerService) DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error {

View File

@ -8,6 +8,12 @@ import (
) )
func Init() { func Init() {
InitAgentDB()
InitTaskDB()
global.LOG.Info("Migration run successfully")
}
func InitAgentDB() {
m := gormigrate.New(global.DB, gormigrate.DefaultOptions, []*gormigrate.Migration{ m := gormigrate.New(global.DB, gormigrate.DefaultOptions, []*gormigrate.Migration{
migrations.AddTable, migrations.AddTable,
migrations.AddMonitorTable, migrations.AddMonitorTable,
@ -20,5 +26,14 @@ func Init() {
global.LOG.Error(err) global.LOG.Error(err)
panic(err) panic(err)
} }
global.LOG.Info("Migration run successfully") }
func InitTaskDB() {
m := gormigrate.New(global.TaskDB, gormigrate.DefaultOptions, []*gormigrate.Migration{
migrations.AddTaskTable,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
panic(err)
}
} }

View File

@ -217,3 +217,12 @@ var InitPHPExtensions = &gormigrate.Migration{
return nil return nil
}, },
} }
var AddTaskTable = &gormigrate.Migration{
ID: "20241226-add-task",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&model.Task{},
)
},
}

View File

@ -23,7 +23,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/list", baseApi.ListContainer) baRouter.POST("/list", baseApi.ListContainer)
baRouter.GET("/status", baseApi.LoadContainerStatus) baRouter.GET("/status", baseApi.LoadContainerStatus)
baRouter.GET("/list/stats", baseApi.ContainerListStats) baRouter.GET("/list/stats", baseApi.ContainerListStats)
baRouter.GET("/search/log", baseApi.ContainerLogs) baRouter.GET("/search/log", baseApi.ContainerStreamLogs)
baRouter.POST("/download/log", baseApi.DownloadContainerLogs) baRouter.POST("/download/log", baseApi.DownloadContainerLogs)
baRouter.GET("/limit", baseApi.LoadResourceLimit) baRouter.GET("/limit", baseApi.LoadResourceLimit)
baRouter.POST("/clean/log", baseApi.CleanContainerLog) baRouter.POST("/clean/log", baseApi.CleanContainerLog)
@ -46,7 +46,6 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/compose/test", baseApi.TestCompose) baRouter.POST("/compose/test", baseApi.TestCompose)
baRouter.POST("/compose/operate", baseApi.OperatorCompose) baRouter.POST("/compose/operate", baseApi.OperatorCompose)
baRouter.POST("/compose/update", baseApi.ComposeUpdate) baRouter.POST("/compose/update", baseApi.ComposeUpdate)
baRouter.GET("/compose/search/log", baseApi.ComposeLogs)
baRouter.GET("/template", baseApi.ListComposeTemplate) baRouter.GET("/template", baseApi.ListComposeTemplate)
baRouter.POST("/template/search", baseApi.SearchComposeTemplate) baRouter.POST("/template/search", baseApi.SearchComposeTemplate)

View File

@ -55,7 +55,8 @@
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-demi": "^0.14.6", "vue-demi": "^0.14.6",
"vue-i18n": "^9.13.1", "vue-i18n": "^9.13.1",
"vue-router": "^4.3.3" "vue-router": "^4.3.3",
"vue-virtual-scroller": "^2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.8", "@types/node": "^20.14.8",

View File

@ -1,7 +1,7 @@
<template> <template>
<DrawerPro <DrawerPro
v-model="open" v-model="open"
:header="$t('commons.button.log')" :header="resource"
:back="handleClose" :back="handleClose"
:size="globalStore.isFullScreen ? 'full' : 'large'" :size="globalStore.isFullScreen ? 'full' : 'large'"
:resource="resource" :resource="resource"
@ -12,72 +12,34 @@
</el-tooltip> </el-tooltip>
</template> </template>
<template #content> <template #content>
<div class="flex flex-wrap"> <ContainerLog :compose="compose" />
<el-select @change="searchLogs" v-model="logSearch.mode" class="selectWidth">
<template #prefix>{{ $t('container.fetch') }}</template>
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
</el-select>
<el-select @change="searchLogs" class="ml-5 selectWidth" v-model.number="logSearch.tail">
<template #prefix>{{ $t('container.lines') }}</template>
<el-option :value="0" :label="$t('commons.table.all')" />
<el-option :value="100" :label="100" />
<el-option :value="200" :label="200" />
<el-option :value="500" :label="500" />
<el-option :value="1000" :label="1000" />
</el-select>
<div class="ml-5">
<el-checkbox border @change="searchLogs" v-model="logSearch.isWatch">
{{ $t('commons.button.watch') }}
</el-checkbox>
</div>
<el-button class="ml-5" @click="onDownload" icon="Download">
{{ $t('file.download') }}
</el-button>
</div>
<div class="mt-2.5">
<highlightjs
ref="editorRef"
class="editor-main"
language="JavaScript"
:autodetect="false"
:code="logInfo"
></highlightjs>
</div>
</template> </template>
</DrawerPro> </DrawerPro>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import i18n from '@/lang'; import i18n from '@/lang';
import { dateFormatForName } from '@/utils/util'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { MsgError } from '@/utils/message';
import { GlobalStore } from '@/store'; import { GlobalStore } from '@/store';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { DownloadFile } from '@/api/modules/container'; import ContainerLog from '@/components/container-log/index.vue';
const logInfo = ref('');
const logSocket = ref<WebSocket>();
const open = ref(false); const open = ref(false);
const resource = ref(''); const resource = ref('');
const globalStore = GlobalStore(); const globalStore = GlobalStore();
const logVisible = ref(false); const logVisible = ref(false);
const editorRef = ref(); const compose = ref('');
const scrollerElement = ref<HTMLElement | null>(null);
interface DialogProps {
compose: string;
resource: string;
}
const mobile = computed(() => { const mobile = computed(() => {
return globalStore.isMobile(); return globalStore.isMobile();
}); });
const logSearch = reactive({
isWatch: true,
compose: '',
mode: 'all',
tail: 500,
});
const handleClose = () => { const handleClose = () => {
logSocket.value?.send('close conn');
open.value = false; open.value = false;
globalStore.isFullScreen = false; globalStore.isFullScreen = false;
}; };
@ -93,104 +55,10 @@ watch(logVisible, (val) => {
if (screenfull.isEnabled && !val && !mobile.value) screenfull.exit(); if (screenfull.isEnabled && !val && !mobile.value) screenfull.exit();
}); });
const timeOptions = ref([
{ label: i18n.global.t('container.all'), value: 'all' },
{
label: i18n.global.t('container.lastDay'),
value: new Date(new Date().getTime() - 3600 * 1000 * 24 * 1).getTime() / 1000 + '',
},
{
label: i18n.global.t('container.last4Hour'),
value: new Date(new Date().getTime() - 3600 * 1000 * 4).getTime() / 1000 + '',
},
{
label: i18n.global.t('container.lastHour'),
value: new Date(new Date().getTime() - 3600 * 1000).getTime() / 1000 + '',
},
{
label: i18n.global.t('container.last10Min'),
value: new Date(new Date().getTime() - 600 * 1000).getTime() / 1000 + '',
},
]);
const searchLogs = async () => {
if (Number(logSearch.tail) < 0) {
MsgError(i18n.global.t('container.linesHelper'));
return;
}
logSocket.value?.send('close conn');
logSocket.value?.close();
logInfo.value = '';
const href = window.location.href;
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
const host = href.split('//')[1].split('/')[0];
logSocket.value = new WebSocket(
`${protocol}://${host}/api/v2/containers/compose/search/log?compose=${logSearch.compose}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}`,
);
logSocket.value.onmessage = (event) => {
logInfo.value += event.data;
nextTick(() => {
scrollerElement.value.scrollTop = scrollerElement.value.scrollHeight;
});
};
};
const onDownload = async () => {
let msg =
logSearch.tail === 0
? i18n.global.t('app.downloadLogHelper1', [resource.value])
: i18n.global.t('app.downloadLogHelper2', [resource.value, logSearch.tail]);
ElMessageBox.confirm(msg, i18n.global.t('file.download'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
let params = {
container: logSearch.compose,
since: logSearch.mode,
tail: logSearch.tail,
containerType: 'compose',
};
let addItem = {};
addItem['name'] = logSearch.compose + '-' + dateFormatForName(new Date()) + '.log';
DownloadFile(params).then((res) => {
const downloadUrl = window.URL.createObjectURL(new Blob([res]));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = addItem['name'];
const event = new MouseEvent('click');
a.dispatchEvent(event);
});
});
};
interface DialogProps {
compose: string;
resource: string;
}
const acceptParams = (props: DialogProps): void => { const acceptParams = (props: DialogProps): void => {
logSearch.compose = props.compose; compose.value = props.compose;
logSearch.tail = 200;
logSearch.mode = timeOptions.value[3].value;
logSearch.isWatch = true;
resource.value = props.resource; resource.value = props.resource;
open.value = true; open.value = true;
if (!mobile.value) {
screenfull.on('change', () => {
globalStore.isFullScreen = screenfull.isFullscreen;
});
}
nextTick(() => {
if (editorRef.value) {
searchLogs();
scrollerElement.value = editorRef.value.$el as HTMLElement;
let hljsDom = scrollerElement.value.querySelector('.hljs') as HTMLElement;
hljsDom.style['min-height'] = '300px';
hljsDom.style['flex-grow'] = 1;
}
});
}; };
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -206,13 +74,4 @@ defineExpose({
.fullScreen { .fullScreen {
border: none; border: none;
} }
.selectWidth {
width: 200px;
}
.editor-main {
display: flex;
flex-direction: column;
height: 80vh;
}
</style> </style>

View File

@ -1,120 +1,158 @@
<template> <template>
<div> <div>
<div class="flex flex-wrap"> <el-select @change="searchLogs" class="fetchClass" v-model="logSearch.mode">
<el-select @change="searchLogs" v-model="logSearch.mode" class="selectWidth"> <template #prefix>{{ $t('container.fetch') }}</template>
<template #prefix>{{ $t('container.fetch') }}</template> <el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" /> </el-select>
</el-select> <el-select @change="searchLogs" class="tailClass" v-model.number="logSearch.tail">
<el-select @change="searchLogs" class="margin-button selectWidth" v-model.number="logSearch.tail"> <template #prefix>{{ $t('container.lines') }}</template>
<template #prefix>{{ $t('container.lines') }}</template> <el-option :value="0" :label="$t('commons.table.all')" />
<el-option :value="0" :label="$t('commons.table.all')" /> <el-option :value="100" :label="100" />
<el-option :value="100" :label="100" /> <el-option :value="200" :label="200" />
<el-option :value="200" :label="200" /> <el-option :value="500" :label="500" />
<el-option :value="500" :label="500" /> <el-option :value="1000" :label="1000" />
<el-option :value="1000" :label="1000" /> </el-select>
</el-select> <div class="margin-button float-left">
<div class="margin-button"> <el-checkbox border @change="searchLogs" v-model="logSearch.isWatch">
<el-checkbox border @change="searchLogs" v-model="logSearch.isWatch"> {{ $t('commons.button.watch') }}
{{ $t('commons.button.watch') }} </el-checkbox>
</el-checkbox>
</div>
<el-button class="margin-button" @click="onDownload" icon="Download">
{{ $t('file.download') }}
</el-button>
<el-button class="margin-button" @click="onClean" icon="Delete">
{{ $t('commons.button.clean') }}
</el-button>
</div> </div>
<LogPro v-model="logInfo" :heightDiff="400" /> <el-button class="margin-button" @click="onDownload" icon="Download">
{{ $t('file.download') }}
</el-button>
<el-button class="margin-button" @click="onClean" icon="Delete">
{{ $t('commons.button.clean') }}
</el-button>
</div>
<div class="log-container" ref="logContainer">
<DynamicScroller :items="logs" :min-item-size="32" v-if="logs.length">
<template #default="{ item, active }">
<DynamicScrollerItem
:item="item"
:active="active"
class="msgBox"
:size-dependencies="[item]"
:data-index="item"
>
<span class="log-item">{{ item }}</span>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { cleanContainerLog } from '@/api/modules/container'; import { cleanContainerLog, DownloadFile } from '@/api/modules/container';
import i18n from '@/lang'; import i18n from '@/lang';
import { dateFormatForName, downloadWithContent } from '@/utils/util'; import { dateFormatForName } from '@/utils/util';
import { onBeforeUnmount, reactive, ref } from 'vue'; import { onUnmounted, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
import LogPro from '@/components/log-pro/index.vue';
const logInfo = ref(); const props = defineProps({
const terminalSocket = ref<WebSocket>(); container: {
type: String,
default: '',
},
compose: {
type: String,
default: '',
},
});
const logVisible = ref(false);
const logContainer = ref<HTMLElement | null>(null);
const logs = ref<string[]>([]);
let eventSource: EventSource | null = null;
const logSearch = reactive({ const logSearch = reactive({
isWatch: false, isWatch: true,
container: '', container: '',
containerID: '',
mode: 'all', mode: 'all',
tail: 100, tail: 100,
compose: '',
}); });
const timeOptions = ref([ const timeOptions = ref([
{ label: i18n.global.t('container.all'), value: 'all' }, { label: i18n.global.t('container.all'), value: 'all' },
{ {
label: i18n.global.t('container.lastDay'), label: i18n.global.t('container.lastDay'),
value: new Date(new Date().getTime() - 3600 * 1000 * 24 * 1).getTime() / 1000 + '', value: '24h',
}, },
{ {
label: i18n.global.t('container.last4Hour'), label: i18n.global.t('container.last4Hour'),
value: new Date(new Date().getTime() - 3600 * 1000 * 4).getTime() / 1000 + '', value: '4h',
}, },
{ {
label: i18n.global.t('container.lastHour'), label: i18n.global.t('container.lastHour'),
value: new Date(new Date().getTime() - 3600 * 1000).getTime() / 1000 + '', value: '1h',
}, },
{ {
label: i18n.global.t('container.last10Min'), label: i18n.global.t('container.last10Min'),
value: new Date(new Date().getTime() - 600 * 1000).getTime() / 1000 + '', value: '10m',
}, },
]); ]);
const stopListening = () => {
if (eventSource) {
eventSource.close();
}
};
const handleClose = async () => {
stopListening();
logVisible.value = false;
};
const searchLogs = async () => { const searchLogs = async () => {
if (Number(logSearch.tail) < 0) { if (Number(logSearch.tail) < 0) {
MsgError(i18n.global.t('container.linesHelper')); MsgError(i18n.global.t('container.linesHelper'));
return; return;
} }
terminalSocket.value?.send('close conn'); logs.value = [];
terminalSocket.value?.close(); let url = `/api/v2/containers/search/log?container=${logSearch.container}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}`;
logInfo.value = ''; if (logSearch.compose !== '') {
const href = window.location.href; url = `/api/v2/containers/search/log?compose=${logSearch.compose}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}`;
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss'; }
const host = href.split('//')[1].split('/')[0]; eventSource = new EventSource(url);
terminalSocket.value = new WebSocket( eventSource.onmessage = (event: MessageEvent) => {
`${protocol}://${host}/api/v2/containers/search/log?container=${logSearch.containerID}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}`, const data = event.data;
); logs.value.push(data);
terminalSocket.value.onmessage = (event) => { nextTick(() => {
logInfo.value += event.data; if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight;
}
});
}; };
}; };
const onDownload = async () => { const onDownload = async () => {
let msg = logSearch.tail = 0;
logSearch.tail === 0 let msg = i18n.global.t('container.downLogHelper1', [logSearch.container]);
? i18n.global.t('container.downLogHelper1', [logSearch.container])
: i18n.global.t('container.downLogHelper2', [logSearch.container, logSearch.tail]);
ElMessageBox.confirm(msg, i18n.global.t('file.download'), { ElMessageBox.confirm(msg, i18n.global.t('file.download'), {
confirmButtonText: i18n.global.t('commons.button.confirm'), confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'), cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info', type: 'info',
}).then(async () => { }).then(async () => {
downloadWithContent(logInfo.value, logSearch.container + '-' + dateFormatForName(new Date()) + '.log'); let params = {
container: logSearch.container,
since: logSearch.mode,
tail: logSearch.tail,
containerType: 'container',
};
let addItem = {};
addItem['name'] = logSearch.container + '-' + dateFormatForName(new Date()) + '.log';
DownloadFile(params).then((res) => {
const downloadUrl = window.URL.createObjectURL(new Blob([res]));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = addItem['name'];
const event = new MouseEvent('click');
a.dispatchEvent(event);
});
}); });
}; };
interface DialogProps {
container: string;
containerID: string;
}
const acceptParams = (props: DialogProps): void => {
logSearch.containerID = props.containerID;
logSearch.tail = 100;
logSearch.mode = 'all';
logSearch.isWatch = false;
logSearch.container = props.container;
searchLogs();
};
const onClean = async () => { const onClean = async () => {
ElMessageBox.confirm(i18n.global.t('container.cleanLogHelper'), i18n.global.t('container.cleanLog'), { ElMessageBox.confirm(i18n.global.t('container.cleanLogHelper'), i18n.global.t('container.cleanLog'), {
confirmButtonText: i18n.global.t('commons.button.confirm'), confirmButtonText: i18n.global.t('commons.button.confirm'),
@ -127,12 +165,20 @@ const onClean = async () => {
}); });
}; };
onBeforeUnmount(() => { onUnmounted(() => {
terminalSocket.value?.send('close conn'); handleClose();
}); });
defineExpose({ onMounted(() => {
acceptParams, logSearch.container = props.container;
logSearch.compose = props.compose;
logVisible.value = true;
logSearch.tail = 100;
logSearch.mode = 'all';
logSearch.isWatch = true;
searchLogs();
}); });
</script> </script>
@ -140,13 +186,34 @@ defineExpose({
.margin-button { .margin-button {
margin-left: 20px; margin-left: 20px;
} }
.selectWidth { .fullScreen {
width: 150px; border: none;
} }
.editor-main { .tailClass {
height: calc(100vh - 480px); width: 20%;
float: left;
margin-left: 20px;
}
.fetchClass {
width: 30%;
float: left;
}
.log-container {
height: calc(100vh - 405px);
overflow-y: auto;
overflow-x: auto;
position: relative;
background-color: #1e1e1e;
margin-top: 10px;
}
.log-item {
position: absolute;
width: 100%; width: 100%;
min-height: 600px; padding: 2px;
overflow: auto; color: #f5f5f5;
box-sizing: border-box;
white-space: nowrap;
} }
</style> </style>

View File

@ -218,7 +218,6 @@ const getContent = async (pre: boolean) => {
} }
nextTick(() => { nextTick(() => {
console.log('pre', pre);
if (pre) { if (pre) {
logContainer.value.scrollTop = 2000; logContainer.value.scrollTop = 2000;
} else { } else {

View File

@ -43,9 +43,12 @@ const open = ref(false);
const showTail = ref(true); const showTail = ref(true);
const openWithTaskID = (id: string, tail: boolean) => { const openWithTaskID = (id: string, tail: boolean) => {
console.log('openWithTaskID', id, tail);
config.taskID = id; config.taskID = id;
if (!tail) { if (!tail) {
config.tail = false; config.tail = false;
} else {
config.tail = true;
} }
open.value = true; open.value = true;
}; };

View File

@ -25,6 +25,9 @@ import Fit2CloudPlus from 'fit2cloud-ui-plus';
import * as Icons from '@element-plus/icons-vue'; import * as Icons from '@element-plus/icons-vue';
import hljsVuePlugin from '@highlightjs/vue-plugin'; import hljsVuePlugin from '@highlightjs/vue-plugin';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import VirtualScroller from 'vue-virtual-scroller';
const app = createApp(App); const app = createApp(App);
app.use(hljsVuePlugin); app.use(hljsVuePlugin);
app.component('SvgIcon', SvgIcon); app.component('SvgIcon', SvgIcon);
@ -35,6 +38,7 @@ Object.keys(Icons).forEach((key) => {
app.component(key, Icons[key as keyof typeof Icons]); app.component(key, Icons[key as keyof typeof Icons]);
}); });
app.use(VirtualScroller);
app.use(router); app.use(router);
app.use(i18n); app.use(i18n);
app.use(pinia); app.use(pinia);

View File

@ -12,32 +12,7 @@
</el-tooltip> </el-tooltip>
</template> </template>
<template #content> <template #content>
<div> <ContainerLog :container="config.container" />
<el-select @change="searchLogs" class="fetchClass" v-model="logSearch.mode">
<template #prefix>{{ $t('container.fetch') }}</template>
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
</el-select>
<el-select @change="searchLogs" class="tailClass" v-model.number="logSearch.tail">
<template #prefix>{{ $t('container.lines') }}</template>
<el-option :value="0" :label="$t('commons.table.all')" />
<el-option :value="100" :label="100" />
<el-option :value="200" :label="200" />
<el-option :value="500" :label="500" />
<el-option :value="1000" :label="1000" />
</el-select>
<div class="margin-button float-left">
<el-checkbox border @change="searchLogs" v-model="logSearch.isWatch">
{{ $t('commons.button.watch') }}
</el-checkbox>
</div>
<el-button class="margin-button" @click="onDownload" icon="Download">
{{ $t('file.download') }}
</el-button>
<el-button class="margin-button" @click="onClean" icon="Delete">
{{ $t('commons.button.clean') }}
</el-button>
</div>
<LogPro v-model="logInfo"></LogPro>
</template> </template>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
@ -48,25 +23,16 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { cleanContainerLog, DownloadFile } from '@/api/modules/container';
import i18n from '@/lang'; import i18n from '@/lang';
import { dateFormatForName } from '@/utils/util';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { GlobalStore } from '@/store'; import { GlobalStore } from '@/store';
import LogPro from '@/components/log-pro/index.vue';
const logVisible = ref(false); const logVisible = ref(false);
const mobile = computed(() => { const mobile = computed(() => {
return globalStore.isMobile(); return globalStore.isMobile();
}); });
const logInfo = ref<string>('');
const globalStore = GlobalStore(); const globalStore = GlobalStore();
const terminalSocket = ref<WebSocket>();
const logSearch = reactive({ const logSearch = reactive({
isWatch: true, isWatch: true,
container: '', container: '',
@ -75,26 +41,6 @@ const logSearch = reactive({
tail: 100, tail: 100,
}); });
const timeOptions = ref([
{ label: i18n.global.t('container.all'), value: 'all' },
{
label: i18n.global.t('container.lastDay'),
value: '24h',
},
{
label: i18n.global.t('container.last4Hour'),
value: '4h',
},
{
label: i18n.global.t('container.lastHour'),
value: '1h',
},
{
label: i18n.global.t('container.last10Min'),
value: '10m',
},
]);
function toggleFullscreen() { function toggleFullscreen() {
globalStore.isFullScreen = !globalStore.isFullScreen; globalStore.isFullScreen = !globalStore.isFullScreen;
} }
@ -102,86 +48,30 @@ function toggleFullscreen() {
const loadTooltip = () => { const loadTooltip = () => {
return i18n.global.t('commons.button.' + (globalStore.isFullScreen ? 'quitFullscreen' : 'fullscreen')); return i18n.global.t('commons.button.' + (globalStore.isFullScreen ? 'quitFullscreen' : 'fullscreen'));
}; };
const handleClose = async () => { const handleClose = async () => {
terminalSocket.value?.send('close conn');
logVisible.value = false; logVisible.value = false;
globalStore.isFullScreen = false; globalStore.isFullScreen = false;
}; };
watch(logVisible, (val) => { watch(logVisible, (val) => {
if (screenfull.isEnabled && !val && !mobile.value) screenfull.exit(); if (screenfull.isEnabled && !val && !mobile.value) screenfull.exit();
}); });
const searchLogs = async () => {
if (Number(logSearch.tail) < 0) {
MsgError(i18n.global.t('container.linesHelper'));
return;
}
terminalSocket.value?.send('close conn');
terminalSocket.value?.close();
logInfo.value = '';
const href = window.location.href;
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
const host = href.split('//')[1].split('/')[0];
terminalSocket.value = new WebSocket(
`${protocol}://${host}/api/v2/containers/search/log?container=${logSearch.containerID}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}`,
);
terminalSocket.value.onmessage = (event) => {
logInfo.value += event.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
};
};
const onDownload = async () => {
logSearch.tail = 0;
let msg = i18n.global.t('container.downLogHelper1', [logSearch.container]);
ElMessageBox.confirm(msg, i18n.global.t('file.download'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
let params = {
container: logSearch.containerID,
since: logSearch.mode,
tail: logSearch.tail,
containerType: 'container',
};
let addItem = {};
addItem['name'] = logSearch.container + '-' + dateFormatForName(new Date()) + '.log';
DownloadFile(params).then((res) => {
const downloadUrl = window.URL.createObjectURL(new Blob([res]));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = addItem['name'];
const event = new MouseEvent('click');
a.dispatchEvent(event);
});
});
};
const onClean = async () => {
ElMessageBox.confirm(i18n.global.t('container.cleanLogHelper'), i18n.global.t('container.cleanLog'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
}).then(async () => {
await cleanContainerLog(logSearch.container);
searchLogs();
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
});
};
interface DialogProps { interface DialogProps {
container: string; container: string;
containerID: string; containerID: string;
} }
const config = ref<DialogProps>({
container: '',
containerID: '',
});
const acceptParams = (props: DialogProps): void => { const acceptParams = (props: DialogProps): void => {
config.value.containerID = props.containerID;
config.value.container = props.container;
logVisible.value = true; logVisible.value = true;
logSearch.containerID = props.containerID;
logSearch.tail = 100;
logSearch.mode = 'all';
logSearch.isWatch = true;
logSearch.container = props.container;
searchLogs();
if (!mobile.value) { if (!mobile.value) {
screenfull.on('change', () => { screenfull.on('change', () => {
@ -200,26 +90,7 @@ defineExpose({
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.margin-button {
margin-left: 20px;
}
.fullScreen { .fullScreen {
border: none; border: none;
} }
.tailClass {
width: 20%;
float: left;
margin-left: 20px;
}
.fetchClass {
width: 30%;
float: left;
}
.editor-main {
height: calc(100vh - 250px);
width: 100%;
min-height: 600px;
overflow: auto;
}
</style> </style>

View File

@ -86,7 +86,7 @@
</el-row> </el-row>
</el-form> </el-form>
</div> </div>
<ContainerLog v-if="activeName === 'log'" ref="dialogContainerLogRef" /> <ContainerLog v-if="activeName === 'log'" :container="containerID" />
<SlowLog <SlowLog
@loading="changeLoading" @loading="changeLoading"
@refresh="loadBaseInfo" @refresh="loadBaseInfo"
@ -155,6 +155,7 @@ const mysqlName = ref();
const mysqlStatus = ref(); const mysqlStatus = ref();
const mysqlVersion = ref(); const mysqlVersion = ref();
const variables = ref(); const variables = ref();
const containerID = ref('');
interface DBProps { interface DBProps {
type: string; type: string;
@ -165,7 +166,6 @@ const props = withDefaults(defineProps<DBProps>(), {
database: '', database: '',
}); });
const dialogContainerLogRef = ref();
const jumpToConf = async () => { const jumpToConf = async () => {
activeName.value = 'conf'; activeName.value = 'conf';
loadMysqlConf(); loadMysqlConf();
@ -275,8 +275,8 @@ const onSaveConf = async () => {
return; return;
}; };
const loadContainerLog = async (containerID: string) => { const loadContainerLog = async (conID: string) => {
dialogContainerLogRef.value!.acceptParams({ containerID: containerID, container: containerID }); containerID.value = conID;
}; };
const loadBaseInfo = async () => { const loadBaseInfo = async () => {

View File

@ -46,7 +46,7 @@
</el-row> </el-row>
</el-form> </el-form>
</div> </div>
<ContainerLog v-show="activeName === 'log'" ref="dialogContainerLogRef" /> <ContainerLog v-if="activeName === 'log'" :container="containerID" />
</template> </template>
</LayoutContent> </LayoutContent>
@ -109,7 +109,7 @@ const props = withDefaults(defineProps<DBProps>(), {
database: '', database: '',
}); });
const dialogContainerLogRef = ref(); const containerID = ref('');
const jumpToConf = async () => { const jumpToConf = async () => {
activeName.value = 'conf'; activeName.value = 'conf';
loadPostgresqlConf(); loadPostgresqlConf();
@ -181,8 +181,8 @@ const onSaveConf = async () => {
return; return;
}; };
const loadContainerLog = async (containerID: string) => { const loadContainerLog = async (conID: string) => {
dialogContainerLogRef.value!.acceptParams({ containerID: containerID, container: containerID }); containerID.value = conID;
}; };
const loadBaseInfo = async () => { const loadBaseInfo = async () => {

View File

@ -31,7 +31,7 @@
<Status v-if="activeName === '1'" :status="status" /> <Status v-if="activeName === '1'" :status="status" />
<Source v-if="activeName === '2'" /> <Source v-if="activeName === '2'" />
<NginxPer v-if="activeName === '3'" /> <NginxPer v-if="activeName === '3'" />
<ContainerLog v-if="activeName === '4'" ref="dialogContainerLogRef" /> <ContainerLog v-if="activeName === '4'" :container="containerName" />
<Module v-if="activeName === '5'" /> <Module v-if="activeName === '5'" />
</template> </template>
</LayoutContent> </LayoutContent>
@ -39,14 +39,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import Source from './source/index.vue'; import Source from './source/index.vue';
import { nextTick, ref } from 'vue'; import { ref } from 'vue';
import ContainerLog from '@/components/container-log/index.vue'; import ContainerLog from '@/components/container-log/index.vue';
import NginxPer from './performance/index.vue'; import NginxPer from './performance/index.vue';
import Status from './status/index.vue'; import Status from './status/index.vue';
import Module from './module/index.vue'; import Module from './module/index.vue';
const activeName = ref('1'); const activeName = ref('1');
const dialogContainerLogRef = ref();
const props = defineProps({ const props = defineProps({
containerName: { containerName: {
@ -60,15 +59,6 @@ const props = defineProps({
}); });
const changeTab = (index: string) => { const changeTab = (index: string) => {
activeName.value = index; activeName.value = index;
if (index === '4') {
nextTick(() => {
dialogContainerLogRef.value!.acceptParams({
containerID: props.containerName,
container: props.containerName,
});
});
}
}; };
onMounted(() => { onMounted(() => {