mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-03-17 03:04:46 +08:00
feat: 增加编排详情界面
This commit is contained in:
parent
415200492a
commit
cecc108784
@ -9,3 +9,9 @@ type ComposeTemplate struct {
|
|||||||
Path string `gorm:"type:varchar(64)" json:"path"`
|
Path string `gorm:"type:varchar(64)" json:"path"`
|
||||||
Content string `gorm:"type:longtext" json:"content"`
|
Content string `gorm:"type:longtext" json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Compose struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
Name string `gorm:"type:varchar(256)" json:"name"`
|
||||||
|
}
|
||||||
|
@ -14,6 +14,9 @@ type IComposeTemplateRepo interface {
|
|||||||
Create(compose *model.ComposeTemplate) error
|
Create(compose *model.ComposeTemplate) error
|
||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
|
|
||||||
|
CreateRecord(compose *model.Compose) error
|
||||||
|
DeleteRecord(opts ...DBOption) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIComposeTemplateRepo() IComposeTemplateRepo {
|
func NewIComposeTemplateRepo() IComposeTemplateRepo {
|
||||||
@ -67,3 +70,23 @@ func (u *ComposeTemplateRepo) Delete(opts ...DBOption) error {
|
|||||||
}
|
}
|
||||||
return db.Delete(&model.ComposeTemplate{}).Error
|
return db.Delete(&model.ComposeTemplate{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ComposeTemplateRepo) ListRecord() ([]model.Compose, error) {
|
||||||
|
var composes []model.Compose
|
||||||
|
if err := global.DB.Find(&composes).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return composes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ComposeTemplateRepo) CreateRecord(compose *model.Compose) error {
|
||||||
|
return global.DB.Create(compose).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ComposeTemplateRepo) DeleteRecord(opts ...DBOption) error {
|
||||||
|
db := global.DB
|
||||||
|
for _, opt := range opts {
|
||||||
|
db = opt(db)
|
||||||
|
}
|
||||||
|
return db.Delete(&model.Compose{}).Error
|
||||||
|
}
|
||||||
|
@ -157,7 +157,7 @@ var AddTableApp = &gormigrate.Migration{
|
|||||||
var AddTableImageRepo = &gormigrate.Migration{
|
var AddTableImageRepo = &gormigrate.Migration{
|
||||||
ID: "20201009-add-table-imagerepo",
|
ID: "20201009-add-table-imagerepo",
|
||||||
Migrate: func(tx *gorm.DB) error {
|
Migrate: func(tx *gorm.DB) error {
|
||||||
if err := tx.AutoMigrate(&model.ImageRepo{}, &model.ComposeTemplate{}); err != nil {
|
if err := tx.AutoMigrate(&model.ImageRepo{}, &model.ComposeTemplate{}, &model.Compose{}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
item := &model.ImageRepo{
|
item := &model.ImageRepo{
|
||||||
|
@ -11,7 +11,7 @@ const containerRouter = {
|
|||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: ':filters?',
|
path: '',
|
||||||
name: 'Container',
|
name: 'Container',
|
||||||
component: () => import('@/views/container/container/index.vue'),
|
component: () => import('@/views/container/container/index.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
@ -20,6 +20,16 @@ const containerRouter = {
|
|||||||
activeMenu: '/containers',
|
activeMenu: '/containers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'composeDetail/:filters?',
|
||||||
|
name: 'ComposeDetail',
|
||||||
|
component: () => import('@/views/container/compose/detail/index.vue'),
|
||||||
|
props: true,
|
||||||
|
hidden: true,
|
||||||
|
meta: {
|
||||||
|
activeMenu: '/containers',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'image',
|
path: 'image',
|
||||||
name: 'Image',
|
name: 'Image',
|
||||||
|
271
frontend/src/views/container/compose/detail/index.vue
Normal file
271
frontend/src/views/container/compose/detail/index.vue
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<Submenu activeName="compose" />
|
||||||
|
<el-card style="margin-top: 20px">
|
||||||
|
<LayoutContent :header="'Compose-' + composeName" back-name="Compose" :reload="true">
|
||||||
|
<div>
|
||||||
|
<el-button icon="VideoPause">停止</el-button>
|
||||||
|
<el-button icon="Delete" plain type="danger">删除</el-button>
|
||||||
|
</div>
|
||||||
|
<el-card style="margin-top: 20px">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>Containers</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ComplexTable
|
||||||
|
:pagination-config="paginationConfig"
|
||||||
|
v-model:selects="selects"
|
||||||
|
:data="data"
|
||||||
|
@search="search"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<el-button-group>
|
||||||
|
<el-button :disabled="checkStatus('start')" @click="onOperate('start')">
|
||||||
|
{{ $t('container.start') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('stop')" @click="onOperate('stop')">
|
||||||
|
{{ $t('container.stop') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('restart')" @click="onOperate('restart')">
|
||||||
|
{{ $t('container.restart') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('kill')" @click="onOperate('kill')">
|
||||||
|
{{ $t('container.kill') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('pause')" @click="onOperate('pause')">
|
||||||
|
{{ $t('container.pause') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('unpause')" @click="onOperate('unpause')">
|
||||||
|
{{ $t('container.unpause') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :disabled="checkStatus('remove')" @click="onOperate('remove')">
|
||||||
|
{{ $t('container.remove') }}
|
||||||
|
</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
</template>
|
||||||
|
<el-table-column type="selection" fix />
|
||||||
|
<el-table-column
|
||||||
|
:label="$t('commons.table.name')"
|
||||||
|
show-overflow-tooltip
|
||||||
|
min-width="100"
|
||||||
|
prop="name"
|
||||||
|
fix
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link @click="onInspect(row.containerID)" type="primary">{{ row.name }}</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:label="$t('container.image')"
|
||||||
|
show-overflow-tooltip
|
||||||
|
min-width="100"
|
||||||
|
prop="imageName"
|
||||||
|
/>
|
||||||
|
<el-table-column :label="$t('commons.table.status')" min-width="50" prop="state" fix />
|
||||||
|
<el-table-column :label="$t('container.upTime')" min-width="100" prop="runTime" fix />
|
||||||
|
<el-table-column
|
||||||
|
prop="createTime"
|
||||||
|
:label="$t('commons.table.date')"
|
||||||
|
:formatter="dateFromat"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<fu-table-operations
|
||||||
|
width="200px"
|
||||||
|
:ellipsis="10"
|
||||||
|
:buttons="buttons"
|
||||||
|
:label="$t('commons.table.operate')"
|
||||||
|
fix
|
||||||
|
/>
|
||||||
|
</ComplexTable>
|
||||||
|
|
||||||
|
<CodemirrorDialog ref="mydetail" />
|
||||||
|
|
||||||
|
<ReNameDialog @search="search" ref="dialogReNameRef" />
|
||||||
|
<ContainerLogDialog ref="dialogContainerLogRef" />
|
||||||
|
<CreateDialog @search="search" ref="dialogCreateRef" />
|
||||||
|
<MonitorDialog ref="dialogMonitorRef" />
|
||||||
|
<TerminalDialog ref="dialogTerminalRef" />
|
||||||
|
</el-card>
|
||||||
|
</LayoutContent>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import Submenu from '@/views/container/index.vue';
|
||||||
|
import LayoutContent from '@/layout/layout-content.vue';
|
||||||
|
import ReNameDialog from '@/views/container/container/rename/index.vue';
|
||||||
|
import CreateDialog from '@/views/container/container/create/index.vue';
|
||||||
|
import MonitorDialog from '@/views/container/container/monitor/index.vue';
|
||||||
|
import ContainerLogDialog from '@/views/container/container/log/index.vue';
|
||||||
|
import TerminalDialog from '@/views/container/container/terminal/index.vue';
|
||||||
|
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
|
||||||
|
import ComplexTable from '@/components/complex-table/index.vue';
|
||||||
|
import { dateFromat } from '@/utils/util';
|
||||||
|
import { ContainerOperator, inspect, searchContainer } from '@/api/modules/container';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import i18n from '@/lang';
|
||||||
|
import { Container } from '@/api/interface/container';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
filters?: string;
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Filters>(), {
|
||||||
|
filters: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const composeName = ref();
|
||||||
|
|
||||||
|
const data = ref();
|
||||||
|
const selects = ref<any>([]);
|
||||||
|
const paginationConfig = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
let filterItem = props.filters ? props.filters : '';
|
||||||
|
composeName.value = props.filters.replaceAll('com.docker.compose.project=', '');
|
||||||
|
let params = {
|
||||||
|
page: paginationConfig.page,
|
||||||
|
pageSize: paginationConfig.pageSize,
|
||||||
|
filters: filterItem,
|
||||||
|
};
|
||||||
|
await searchContainer(params).then((res) => {
|
||||||
|
data.value = res.data.items || [];
|
||||||
|
paginationConfig.total = res.data.total;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailInfo = ref();
|
||||||
|
const mydetail = ref();
|
||||||
|
const onInspect = async (id: string) => {
|
||||||
|
const res = await inspect({ id: id, type: 'container' });
|
||||||
|
detailInfo.value = JSON.stringify(JSON.parse(res.data), null, 2);
|
||||||
|
let param = {
|
||||||
|
header: i18n.global.t('commons.button.view'),
|
||||||
|
detailInfo: detailInfo.value,
|
||||||
|
};
|
||||||
|
mydetail.value!.acceptParams(param);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatus = (operation: string) => {
|
||||||
|
if (selects.value.length < 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
switch (operation) {
|
||||||
|
case 'start':
|
||||||
|
for (const item of selects.value) {
|
||||||
|
if (item.state === 'running') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case 'stop':
|
||||||
|
for (const item of selects.value) {
|
||||||
|
if (item.state === 'stopped' || item.state === 'exited') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case 'pause':
|
||||||
|
for (const item of selects.value) {
|
||||||
|
if (item.state === 'paused' || item.state === 'exited') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case 'unpause':
|
||||||
|
for (const item of selects.value) {
|
||||||
|
if (item.state !== 'paused') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOperate = async (operation: string) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
i18n.global.t('container.operatorHelper', [i18n.global.t('container.' + operation)]),
|
||||||
|
i18n.global.t('container.' + operation),
|
||||||
|
{
|
||||||
|
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||||
|
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||||
|
type: 'info',
|
||||||
|
},
|
||||||
|
).then(() => {
|
||||||
|
let ps = [];
|
||||||
|
for (const item of selects.value) {
|
||||||
|
const param = {
|
||||||
|
containerID: item.containerID,
|
||||||
|
operation: operation,
|
||||||
|
newName: '',
|
||||||
|
};
|
||||||
|
ps.push(ContainerOperator(param));
|
||||||
|
}
|
||||||
|
Promise.all(ps)
|
||||||
|
.then(() => {
|
||||||
|
search();
|
||||||
|
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
search();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogMonitorRef = ref();
|
||||||
|
const onMonitor = (containerID: string) => {
|
||||||
|
dialogMonitorRef.value!.acceptParams({ containerID: containerID });
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTerminalRef = ref();
|
||||||
|
const onTerminal = (containerID: string) => {
|
||||||
|
dialogTerminalRef.value!.acceptParams({ containerID: containerID });
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogContainerLogRef = ref();
|
||||||
|
const dialogReNameRef = ref();
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: i18n.global.t('file.terminal'),
|
||||||
|
disabled: (row: Container.ContainerInfo) => {
|
||||||
|
return row.state !== 'running';
|
||||||
|
},
|
||||||
|
click: (row: Container.ContainerInfo) => {
|
||||||
|
onTerminal(row.containerID);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.global.t('container.monitor'),
|
||||||
|
disabled: (row: Container.ContainerInfo) => {
|
||||||
|
return row.state !== 'running';
|
||||||
|
},
|
||||||
|
click: (row: Container.ContainerInfo) => {
|
||||||
|
onMonitor(row.containerID);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.global.t('container.rename'),
|
||||||
|
click: (row: Container.ContainerInfo) => {
|
||||||
|
dialogReNameRef.value!.acceptParams({ containerID: row.containerID, container: row.name });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.global.t('commons.button.log'),
|
||||||
|
click: (row: Container.ContainerInfo) => {
|
||||||
|
dialogContainerLogRef.value!.acceptParams({ containerID: row.containerID });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
onMounted(() => {
|
||||||
|
search();
|
||||||
|
});
|
||||||
|
</script>
|
@ -7,28 +7,7 @@
|
|||||||
<el-button icon="Plus" type="primary" @click="onOpenDialog()">
|
<el-button icon="Plus" type="primary" @click="onOpenDialog()">
|
||||||
{{ $t('commons.button.create') }}
|
{{ $t('commons.button.create') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button-group style="margin-left: 10px">
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('up')">
|
|
||||||
{{ $t('container.start') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('stop')">
|
|
||||||
{{ $t('container.stop') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('pause')">
|
|
||||||
{{ $t('container.pause') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('unpause')">
|
|
||||||
{{ $t('container.unpause') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('restart')">
|
|
||||||
{{ $t('container.restart') }}
|
|
||||||
</el-button>
|
|
||||||
<el-button :disabled="selects.length === 0" @click="onOperate('down')">
|
|
||||||
{{ $t('container.down') }}
|
|
||||||
</el-button>
|
|
||||||
</el-button-group>
|
|
||||||
</template>
|
</template>
|
||||||
<el-table-column type="selection" fix></el-table-column>
|
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:label="$t('commons.table.name')"
|
:label="$t('commons.table.name')"
|
||||||
show-overflow-tooltip
|
show-overflow-tooltip
|
||||||
@ -42,38 +21,35 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('container.from')" prop="createdBy" min-width="80" fix />
|
<el-table-column :label="$t('container.from')" prop="createdBy" min-width="80" fix />
|
||||||
<el-table-column :label="$t('container.containerNumber')" prop="containerNumber" min-width="80" fix />
|
<el-table-column :label="$t('container.containerNumber')" prop="containerNumber" min-width="80" fix />
|
||||||
<el-table-column :label="$t('container.container')" prop="contaienrs" min-width="80" fix>
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div v-for="(item, index) in row.containers" :key="index">
|
|
||||||
<div v-if="row.expand || (!row.expand && index < 3)">
|
|
||||||
<el-tag>{{ item.name }} [{{ item.state }}]</el-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!row.expand && row.containers.length > 3">
|
|
||||||
<el-button type="primary" link @click="row.expand = true">
|
|
||||||
{{ $t('commons.button.expand') }}...
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column :label="$t('commons.table.createdAt')" prop="createdAt" min-width="80" fix />
|
<el-table-column :label="$t('commons.table.createdAt')" prop="createdAt" min-width="80" fix />
|
||||||
|
<fu-table-operations
|
||||||
|
width="200px"
|
||||||
|
:ellipsis="10"
|
||||||
|
:buttons="buttons"
|
||||||
|
:label="$t('commons.table.operate')"
|
||||||
|
fix
|
||||||
|
/>
|
||||||
</ComplexTable>
|
</ComplexTable>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<OperatorDialog @search="search" ref="dialogRef" />
|
<EditDialog ref="dialogEditRef" />
|
||||||
|
<CreateDialog @search="search" ref="dialogRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import ComplexTable from '@/components/complex-table/index.vue';
|
import ComplexTable from '@/components/complex-table/index.vue';
|
||||||
import { reactive, onMounted, ref } from 'vue';
|
import { reactive, onMounted, ref } from 'vue';
|
||||||
import OperatorDialog from '@/views/container/compose/operator/index.vue';
|
import CreateDialog from '@/views/container/compose/create/index.vue';
|
||||||
|
import EditDialog from '@/views/container/compose/edit/index.vue';
|
||||||
import Submenu from '@/views/container/index.vue';
|
import Submenu from '@/views/container/index.vue';
|
||||||
import { ComposeOperator, searchCompose } from '@/api/modules/container';
|
import { composeOperator, searchCompose } from '@/api/modules/container';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import router from '@/routers';
|
import router from '@/routers';
|
||||||
|
import { Container } from '@/api/interface/container';
|
||||||
|
import { useDeleteData } from '@/hooks/use-delete-data';
|
||||||
|
import { LoadFile } from '@/api/modules/files';
|
||||||
|
|
||||||
const data = ref();
|
const data = ref();
|
||||||
const selects = ref<any>([]);
|
const selects = ref<any>([]);
|
||||||
@ -103,7 +79,7 @@ const search = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goContainer = async (name: string) => {
|
const goContainer = async (name: string) => {
|
||||||
router.push({ name: 'Container', params: { filters: 'com.docker.compose.project=' + name } });
|
router.push({ name: 'ComposeDetail', params: { filters: 'com.docker.compose.project=' + name } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogRef = ref();
|
const dialogRef = ref();
|
||||||
@ -111,35 +87,41 @@ const onOpenDialog = async () => {
|
|||||||
dialogRef.value!.acceptParams();
|
dialogRef.value!.acceptParams();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOperate = async (operation: string) => {
|
const onDelete = async (row: Container.ComposeInfo) => {
|
||||||
ElMessageBox.confirm(
|
const param = {
|
||||||
i18n.global.t('container.operatorComposeHelper', [i18n.global.t('container.' + operation)]),
|
name: row.name,
|
||||||
i18n.global.t('container.' + operation),
|
path: row.path,
|
||||||
{
|
operation: 'down',
|
||||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
};
|
||||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
await useDeleteData(composeOperator, param, 'commons.msg.delete');
|
||||||
type: 'info',
|
search();
|
||||||
},
|
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
|
||||||
).then(() => {
|
|
||||||
let ps = [];
|
|
||||||
for (const item of selects.value) {
|
|
||||||
const param = {
|
|
||||||
path: item.path,
|
|
||||||
operation: operation,
|
|
||||||
};
|
|
||||||
ps.push(ComposeOperator(param));
|
|
||||||
}
|
|
||||||
Promise.all(ps)
|
|
||||||
.then(() => {
|
|
||||||
search();
|
|
||||||
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
search();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogEditRef = ref();
|
||||||
|
const onEdit = async (row: Container.ComposeInfo) => {
|
||||||
|
const res = await LoadFile({ path: row.path });
|
||||||
|
let params = {
|
||||||
|
path: row.path,
|
||||||
|
content: res.data,
|
||||||
|
};
|
||||||
|
dialogEditRef.value!.acceptParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: i18n.global.t('commons.button.edit'),
|
||||||
|
click: (row: Container.ComposeInfo) => {
|
||||||
|
onEdit(row);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.global.t('commons.button.delete'),
|
||||||
|
click: (row: Container.ComposeInfo) => {
|
||||||
|
onDelete(row);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
search();
|
search();
|
||||||
});
|
});
|
||||||
|
@ -69,75 +69,8 @@
|
|||||||
|
|
||||||
<CodemirrorDialog ref="mydetail" />
|
<CodemirrorDialog ref="mydetail" />
|
||||||
|
|
||||||
<el-dialog
|
<ReNameDialog @search="search" ref="dialogReNameRef" />
|
||||||
@close="onCloseLog"
|
<ContainerLogDialog ref="dialogContainerLogRef" />
|
||||||
v-model="logVisiable"
|
|
||||||
:destroy-on-close="true"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
width="70%"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span>{{ $t('commons.button.log') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
<el-select @change="searchLogs" style="width: 10%; float: left" v-model="logSearch.mode">
|
|
||||||
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
|
|
||||||
</el-select>
|
|
||||||
<div style="margin-left: 20px; float: left">
|
|
||||||
<el-checkbox border v-model="logSearch.isWatch">{{ $t('commons.button.watch') }}</el-checkbox>
|
|
||||||
</div>
|
|
||||||
<el-button style="margin-left: 20px" @click="onDownload" icon="Download">
|
|
||||||
{{ $t('file.download') }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<codemirror
|
|
||||||
:autofocus="true"
|
|
||||||
placeholder="None data"
|
|
||||||
:indent-with-tab="true"
|
|
||||||
:tabSize="4"
|
|
||||||
style="margin-top: 10px; max-height: 500px"
|
|
||||||
:lineWrapping="true"
|
|
||||||
:matchBrackets="true"
|
|
||||||
theme="cobalt"
|
|
||||||
:styleActiveLine="true"
|
|
||||||
:extensions="extensions"
|
|
||||||
v-model="logInfo"
|
|
||||||
:readOnly="true"
|
|
||||||
/>
|
|
||||||
<template #footer>
|
|
||||||
<span class="dialog-footer">
|
|
||||||
<el-button @click="logVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
@close="search()"
|
|
||||||
v-model="newNameVisiable"
|
|
||||||
:destroy-on-close="true"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
width="30%"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span>{{ $t('container.rename') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-form ref="newNameRef" :model="renameForm">
|
|
||||||
<el-form-item :label="$t('container.newName')" :rules="Rules.requiredInput" prop="newName">
|
|
||||||
<el-input v-model="renameForm.newName"></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<span class="dialog-footer">
|
|
||||||
<el-button @click="newNameVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
|
||||||
<el-button @click="onSubmitName(newNameRef)">{{ $t('commons.button.confirm') }}</el-button>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
<CreateDialog @search="search" ref="dialogCreateRef" />
|
<CreateDialog @search="search" ref="dialogCreateRef" />
|
||||||
<MonitorDialog ref="dialogMonitorRef" />
|
<MonitorDialog ref="dialogMonitorRef" />
|
||||||
<TerminalDialog ref="dialogTerminalRef" />
|
<TerminalDialog ref="dialogTerminalRef" />
|
||||||
@ -146,20 +79,18 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import ComplexTable from '@/components/complex-table/index.vue';
|
import ComplexTable from '@/components/complex-table/index.vue';
|
||||||
|
import ReNameDialog from '@/views/container/container/rename/index.vue';
|
||||||
import CreateDialog from '@/views/container/container/create/index.vue';
|
import CreateDialog from '@/views/container/container/create/index.vue';
|
||||||
import MonitorDialog from '@/views/container/container/monitor/index.vue';
|
import MonitorDialog from '@/views/container/container/monitor/index.vue';
|
||||||
|
import ContainerLogDialog from '@/views/container/container/log/index.vue';
|
||||||
import TerminalDialog from '@/views/container/container/terminal/index.vue';
|
import TerminalDialog from '@/views/container/container/terminal/index.vue';
|
||||||
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
|
import CodemirrorDialog from '@/components/codemirror-dialog/codemirror.vue';
|
||||||
import Submenu from '@/views/container/index.vue';
|
import Submenu from '@/views/container/index.vue';
|
||||||
import { Codemirror } from 'vue-codemirror';
|
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
import { reactive, onMounted, ref } from 'vue';
|
import { reactive, onMounted, ref } from 'vue';
|
||||||
import { dateFromat, dateFromatForName } from '@/utils/util';
|
import { dateFromat } from '@/utils/util';
|
||||||
import { Rules } from '@/global/form-rules';
|
import { ContainerOperator, inspect, searchContainer } from '@/api/modules/container';
|
||||||
import { ContainerOperator, inspect, logContainer, searchContainer } from '@/api/modules/container';
|
|
||||||
import { Container } from '@/api/interface/container';
|
import { Container } from '@/api/interface/container';
|
||||||
import { ElForm, ElMessage, ElMessageBox, FormInstance } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
|
|
||||||
const data = ref();
|
const data = ref();
|
||||||
@ -180,45 +111,8 @@ const props = withDefaults(defineProps<Filters>(), {
|
|||||||
const detailInfo = ref();
|
const detailInfo = ref();
|
||||||
const mydetail = ref();
|
const mydetail = ref();
|
||||||
|
|
||||||
const extensions = [javascript(), oneDark];
|
const dialogContainerLogRef = ref();
|
||||||
const logVisiable = ref<boolean>(false);
|
const dialogReNameRef = ref();
|
||||||
const logInfo = ref();
|
|
||||||
const logSearch = reactive({
|
|
||||||
isWatch: false,
|
|
||||||
container: '',
|
|
||||||
containerID: '',
|
|
||||||
mode: 'all',
|
|
||||||
});
|
|
||||||
let timer: NodeJS.Timer | null = null;
|
|
||||||
|
|
||||||
const newNameVisiable = ref<boolean>(false);
|
|
||||||
type FormInstance = InstanceType<typeof ElForm>;
|
|
||||||
const newNameRef = ref<FormInstance>();
|
|
||||||
const renameForm = reactive({
|
|
||||||
containerID: '',
|
|
||||||
operation: 'rename',
|
|
||||||
newName: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
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 search = async () => {
|
const search = async () => {
|
||||||
let filterItem = props.filters ? props.filters : '';
|
let filterItem = props.filters ? props.filters : '';
|
||||||
@ -228,9 +122,8 @@ const search = async () => {
|
|||||||
filters: filterItem,
|
filters: filterItem,
|
||||||
};
|
};
|
||||||
await searchContainer(params).then((res) => {
|
await searchContainer(params).then((res) => {
|
||||||
if (res.data) {
|
data.value = res.data.items || [];
|
||||||
data.value = res.data.items;
|
paginationConfig.total = res.data.total;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -259,50 +152,6 @@ const onInspect = async (id: string) => {
|
|||||||
mydetail.value!.acceptParams(param);
|
mydetail.value!.acceptParams(param);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLog = async (row: Container.ContainerInfo) => {
|
|
||||||
logSearch.container = row.name;
|
|
||||||
logSearch.containerID = row.containerID;
|
|
||||||
searchLogs();
|
|
||||||
logVisiable.value = true;
|
|
||||||
timer = setInterval(() => {
|
|
||||||
if (logVisiable.value && logSearch.isWatch) {
|
|
||||||
searchLogs();
|
|
||||||
}
|
|
||||||
}, 1000 * 5);
|
|
||||||
};
|
|
||||||
const onCloseLog = async () => {
|
|
||||||
clearInterval(Number(timer));
|
|
||||||
};
|
|
||||||
const searchLogs = async () => {
|
|
||||||
const res = await logContainer(logSearch);
|
|
||||||
logInfo.value = res.data;
|
|
||||||
};
|
|
||||||
const onDownload = async () => {
|
|
||||||
const downloadUrl = window.URL.createObjectURL(new Blob([logInfo.value]));
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = logSearch.container + '-' + dateFromatForName(new Date()) + '.log';
|
|
||||||
const event = new MouseEvent('click');
|
|
||||||
a.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRename = async (row: Container.ContainerInfo) => {
|
|
||||||
renameForm.containerID = row.containerID;
|
|
||||||
renameForm.newName = '';
|
|
||||||
newNameVisiable.value = true;
|
|
||||||
};
|
|
||||||
const onSubmitName = async (formEl: FormInstance | undefined) => {
|
|
||||||
if (!formEl) return;
|
|
||||||
formEl.validate(async (valid) => {
|
|
||||||
if (!valid) return;
|
|
||||||
await ContainerOperator(renameForm);
|
|
||||||
search();
|
|
||||||
newNameVisiable.value = false;
|
|
||||||
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkStatus = (operation: string) => {
|
const checkStatus = (operation: string) => {
|
||||||
if (selects.value.length < 1) {
|
if (selects.value.length < 1) {
|
||||||
return true;
|
return true;
|
||||||
@ -390,13 +239,13 @@ const buttons = [
|
|||||||
{
|
{
|
||||||
label: i18n.global.t('container.rename'),
|
label: i18n.global.t('container.rename'),
|
||||||
click: (row: Container.ContainerInfo) => {
|
click: (row: Container.ContainerInfo) => {
|
||||||
onRename(row);
|
dialogReNameRef.value!.acceptParams({ containerID: row.containerID, container: row.name });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: i18n.global.t('commons.button.log'),
|
label: i18n.global.t('commons.button.log'),
|
||||||
click: (row: Container.ContainerInfo) => {
|
click: (row: Container.ContainerInfo) => {
|
||||||
onLog(row);
|
dialogContainerLogRef.value!.acceptParams({ containerID: row.containerID });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
131
frontend/src/views/container/container/log/index.vue
Normal file
131
frontend/src/views/container/container/log/index.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="logVisiable"
|
||||||
|
@close="onCloseLog"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
width="70%"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>{{ $t('commons.button.log') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<el-select @change="searchLogs" style="width: 10%; float: left" v-model="logSearch.mode">
|
||||||
|
<el-option v-for="item in timeOptions" :key="item.label" :value="item.value" :label="item.label" />
|
||||||
|
</el-select>
|
||||||
|
<div style="margin-left: 20px; float: left">
|
||||||
|
<el-checkbox border v-model="logSearch.isWatch">{{ $t('commons.button.watch') }}</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<el-button style="margin-left: 20px" @click="onDownload" icon="Download">
|
||||||
|
{{ $t('file.download') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<codemirror
|
||||||
|
:autofocus="true"
|
||||||
|
placeholder="None data"
|
||||||
|
:indent-with-tab="true"
|
||||||
|
:tabSize="4"
|
||||||
|
style="margin-top: 10px; max-height: 500px"
|
||||||
|
:lineWrapping="true"
|
||||||
|
:matchBrackets="true"
|
||||||
|
theme="cobalt"
|
||||||
|
:styleActiveLine="true"
|
||||||
|
:extensions="extensions"
|
||||||
|
v-model="logInfo"
|
||||||
|
:readOnly="true"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="logVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { logContainer } from '@/api/modules/container';
|
||||||
|
import i18n from '@/lang';
|
||||||
|
import { dateFromatForName } from '@/utils/util';
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { Codemirror } from 'vue-codemirror';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
|
||||||
|
const extensions = [javascript(), oneDark];
|
||||||
|
|
||||||
|
const logVisiable = ref(false);
|
||||||
|
|
||||||
|
const logInfo = ref();
|
||||||
|
const logSearch = reactive({
|
||||||
|
isWatch: false,
|
||||||
|
container: '',
|
||||||
|
containerID: '',
|
||||||
|
mode: 'all',
|
||||||
|
});
|
||||||
|
let timer: NodeJS.Timer | null = null;
|
||||||
|
|
||||||
|
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 onCloseLog = async () => {
|
||||||
|
clearInterval(Number(timer));
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLogs = async () => {
|
||||||
|
const res = await logContainer(logSearch);
|
||||||
|
logInfo.value = res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
const downloadUrl = window.URL.createObjectURL(new Blob([logInfo.value]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = logSearch.container + '-' + dateFromatForName(new Date()) + '.log';
|
||||||
|
const event = new MouseEvent('click');
|
||||||
|
a.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
container: string;
|
||||||
|
containerID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptParams = (props: DialogProps): void => {
|
||||||
|
logVisiable.value = true;
|
||||||
|
logSearch.containerID = props.containerID;
|
||||||
|
logSearch.mode = 'all';
|
||||||
|
logSearch.isWatch = false;
|
||||||
|
logSearch.container = props.container;
|
||||||
|
searchLogs();
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (logSearch.isWatch) {
|
||||||
|
searchLogs();
|
||||||
|
}
|
||||||
|
}, 1000 * 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
acceptParams,
|
||||||
|
});
|
||||||
|
</script>
|
78
frontend/src/views/container/container/reName/index.vue
Normal file
78
frontend/src/views/container/container/reName/index.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
@close="onClose()"
|
||||||
|
v-model="newNameVisiable"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
width="30%"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>{{ $t('container.rename') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form ref="newNameRef" :model="renameForm">
|
||||||
|
<el-form-item :label="$t('container.newName')" :rules="Rules.requiredInput" prop="newName">
|
||||||
|
<el-input v-model="renameForm.newName"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="newNameVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" @click="onSubmitName(newNameRef)">
|
||||||
|
{{ $t('commons.button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ContainerOperator } from '@/api/modules/container';
|
||||||
|
import { Rules } from '@/global/form-rules';
|
||||||
|
import i18n from '@/lang';
|
||||||
|
import { ElForm, ElMessage } from 'element-plus';
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
const renameForm = reactive({
|
||||||
|
containerID: '',
|
||||||
|
operation: 'rename',
|
||||||
|
newName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const newNameRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
const newNameVisiable = ref<boolean>(false);
|
||||||
|
type FormInstance = InstanceType<typeof ElForm>;
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'search'): void }>();
|
||||||
|
|
||||||
|
const onSubmitName = async (formEl: FormInstance | undefined) => {
|
||||||
|
if (!formEl) return;
|
||||||
|
formEl.validate(async (valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
await ContainerOperator(renameForm);
|
||||||
|
emit('search');
|
||||||
|
newNameVisiable.value = false;
|
||||||
|
ElMessage.success(i18n.global.t('commons.msg.operationSuccess'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
containerID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptParams = (props: DialogProps): void => {
|
||||||
|
renameForm.containerID = props.containerID;
|
||||||
|
renameForm.newName = '';
|
||||||
|
newNameVisiable.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
emit('search');
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
acceptParams,
|
||||||
|
});
|
||||||
|
</script>
|
Loading…
x
Reference in New Issue
Block a user