feat: 首页全新改版

pull/243/head
xiaojunnuo 2024-10-31 15:14:56 +08:00
parent e5e468a463
commit 63ec5b5519
14 changed files with 492 additions and 53 deletions

View File

@ -30,7 +30,7 @@ export class CertConverter {
// 转der
derPath = await this.convertDer(ctx);
//jksPath = await this.convertJks(ctx, pfxPath, opts.pfxPassword);
//jksPath = await this.convertJks(ctx, opts.pfxPassword);
};
await certReader.readCertFile({ logger: this.logger, handle });
@ -64,7 +64,7 @@ export class CertConverter {
if (pfxPassword) {
passwordArg = `-password pass:${pfxPassword}`;
}
// 兼容server 2016
// 兼容server 2016旧版本不能用sha256
const oldPfxCmd = `openssl pkcs12 -macalg SHA1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -export -out ${pfxPath} -inkey ${tmpKeyPath} -in ${tmpCrtPath} ${passwordArg}`;
// const newPfx = `openssl pkcs12 -export -out ${pfxPath} -inkey ${tmpKeyPath} -in ${tmpCrtPath} ${passwordArg}`;
await this.exec(oldPfxCmd);
@ -98,18 +98,18 @@ export class CertConverter {
// this.saveFile(filename, fileBuffer);
}
async convertJks(opts: CertReaderHandleContext, pfxPath: string, pfxPassword = "") {
async convertJks(opts: CertReaderHandleContext, pfxPassword = "") {
const jksPassword = pfxPassword || "123456";
try {
const randomStr = Math.floor(Math.random() * 1000000) + "";
// const p12Path = path.join(os.tmpdir(), "/certd/tmp/", randomStr + `_cert.p12`);
// const { tmpCrtPath, tmpKeyPath } = opts;
// let passwordArg = "-passout pass:";
// if (pfxPassword) {
// passwordArg = `-password pass:${pfxPassword}`;
// }
// await this.exec(`openssl pkcs12 -export -in ${tmpCrtPath} -inkey ${tmpKeyPath} -out ${p12Path} -name certd ${passwordArg}`);
const p12Path = path.join(os.tmpdir(), "/certd/tmp/", randomStr + `_cert.p12`);
const { tmpCrtPath, tmpKeyPath } = opts;
let passwordArg = "-passout pass:";
if (pfxPassword) {
passwordArg = `-password pass:${pfxPassword}`;
}
await this.exec(`openssl pkcs12 -export -in ${tmpCrtPath} -inkey ${tmpKeyPath} -out ${p12Path} -name certd ${passwordArg}`);
const jksPath = path.join(os.tmpdir(), "/certd/tmp/", randomStr + `_cert.jks`);
const dir = path.dirname(jksPath);
@ -117,8 +117,9 @@ export class CertConverter {
fs.mkdirSync(dir, { recursive: true });
}
await this.exec(
`keytool -importkeystore -srckeystore ${pfxPath} -srcstoretype PKCS12 -srcstorepass "${pfxPassword}" -destkeystore ${jksPath} -deststoretype PKCS12 -deststorepass "${jksPassword}" `
`keytool -importkeystore -srckeystore ${p12Path} -srcstoretype PKCS12 -srcstorepass "${pfxPassword}" -destkeystore ${jksPath} -deststoretype PKCS12 -deststorepass "${jksPassword}" `
);
fs.unlinkSync(p12Path);
return jksPath;
} catch (e) {
this.logger.error("转换jks失败", e);

View File

@ -45,6 +45,7 @@
"cron-parser": "^4.9.0",
"cropperjs": "^1.6.1",
"dayjs": "^1.11.10",
"echarts": "^5.5.1",
"highlight.js": "^11.9.0",
"humanize-duration": "^3.27.3",
"lodash-es": "^4.17.21",
@ -58,6 +59,7 @@
"sortablejs": "^1.15.2",
"vue": "^3.4.21",
"vue-cropperjs": "^5.0.0",
"vue-echarts": "^7.0.3",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0",
"vuedraggable": "^4.1.0"

View File

@ -98,8 +98,7 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
vModel: "modelValue",
placeholder: "0 0 4 * * *"
},
helper:
"点击上面的按钮,选择每天几点几分定时执行,后面的分秒都要选择0。\n例如0 0 4 * * *每天凌晨4点0分0秒触发\n建议设置为每天触发一次证书未到期之前任务会跳过不会重复执行",
helper: "点击上面的按钮,选择每天几点几分定时执行,后面的分秒都要选择0。\n建议设置为每天触发一次证书未到期之前任务会跳过不会重复执行",
order: 100
}
},

View File

@ -3,7 +3,7 @@
v-model:open="triggerDrawerVisible"
placement="right"
:closable="true"
width="600px"
width="650px"
class="pi-trigger-form"
@after-open-change="triggerDrawerOnAfterVisibleChange"
>
@ -58,8 +58,7 @@
name: 'cron-editor',
vModel: 'modelValue'
},
helper:
'点击上面的按钮选择每天几点几分定时执行后面的分秒都要选择0。\n例如0 0 4 * * *每天凌晨4点0分0秒触发\n建议设置为每天触发一次证书未到期之前任务会跳过不会重复执行',
helper: '点击上面的按钮选择每天几点几分定时执行后面的分秒都要选择0。\n建议设置为每天触发一次证书未到期之前任务会跳过不会重复执行',
rules: [{ required: true, message: '此项必填' }]
}"
/>

View File

@ -0,0 +1,8 @@
import { request } from "/@/api/service";
export async function GetStatisticCount() {
return await request({
url: "/statistic/count",
method: "POST"
});
}

View File

@ -0,0 +1,4 @@
export type ChartItem = {
name: string;
value: number;
};

View File

@ -0,0 +1,104 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { PieChart, LineChart } from "echarts/charts";
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from "echarts/components";
import VChart, { THEME_KEY } from "vue-echarts";
import { ref, provide, defineProps } from "vue";
import { ChartItem } from "/@/views/framework/home/dashboard/charts/d";
use([CanvasRenderer, PieChart, LineChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
provide(THEME_KEY, "");
const props = defineProps<{
data: ChartItem[];
}>();
const dates = props.data.map((item) => {
return item.name;
});
const counts = props.data.map((item) => {
return item.value;
});
var noDataOption = {
// 使 noData
graphic: {
type: "text",
left: "center",
top: "center",
style: {
text: "无数据",
textAlign: "center",
fill: "#ccc"
}
}
};
const option = ref({
noDataSchema: noDataOption,
color: ["#91cc75", "#73c0de", "#ee6666", "#fac858", "#3ba272", "#fc8452", "#9a60b4", "#ea7ccc", "#5470c6"],
// title: {
// text: "",
// left: "center"
// },
tooltip: {
trigger: "item"
},
// tooltip: {
// trigger: "axis",
// axisPointer: {
// type: "cross",
// label: {
// backgroundColor: "#6a7985"
// }
// }
// },
// legend: {
// data: ["Email",]
// },
grid: {
top: "20px",
left: "20px",
right: "20px",
bottom: "10px",
containLabel: true
},
xAxis: [
{
type: "category",
boundaryGap: false,
data: dates
}
],
yAxis: [
{
type: "value"
}
],
series: [
{
name: "运行次数",
type: "line",
stack: "Total",
label: {
show: true,
position: "top"
},
smooth: true,
areaStyle: {},
emphasis: {
focus: "series"
},
data: counts
}
]
});
</script>
<style lang="less"></style>

View File

@ -0,0 +1,51 @@
<template>
<div v-if="data.length !== 0" class="expiring-pipeline-list">
<div v-for="item of data" class="pipeline-row">
<div class="title" :title="item.title">
<pi-status-show :status="item.status"></pi-status-show> <a @click="goDetail(item)">{{ item.title }}</a>
</div>
<div class="time">
<FsTimeHumanize v-model="item.lastVars.certExpiresTime" :use-format-greater="1000000000000" :options="{ units: ['d'] }"></FsTimeHumanize>
</div>
</div>
</div>
<div v-else class="flex-center flex-1 flex-col">
<a-empty> </a-empty>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from "vue-router";
import PiStatusShow from "/@/views/certd/pipeline/pipeline/component/status-show.vue";
const props = defineProps<{
data: any[];
}>();
const router = useRouter();
function goDetail(item) {
router.push({ path: "/certd/pipeline/detail", query: { id: item.id } });
}
</script>
<style lang="less">
.expiring-pipeline-list {
padding-top: 5px;
.pipeline-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
.title {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
//
white-space: nowrap;
text-overflow: ellipsis;
}
.time {
margin-left: 10px;
width: 70px;
text-align: left;
}
}
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<v-chart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { PieChart } from "echarts/charts";
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from "echarts/components";
import VChart, { THEME_KEY } from "vue-echarts";
import { ref, provide, defineProps } from "vue";
import { ChartItem } from "./d";
use([CanvasRenderer, PieChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
provide(THEME_KEY, "");
const props = defineProps<{
data: ChartItem[];
}>();
const option = ref({
color: ["#91cc75", "#73c0de", "#ee6666", "#fac858", "#5470c6", "#3ba272", "#fc8452", "#9a60b4", "#ea7ccc", "#5470c6"],
tooltip: {
trigger: "item"
},
legend: {
orient: "vertical",
bottom: "5%",
left: "left"
},
grid: {
top: "20px",
left: "20px",
right: "20px",
bottom: "10px",
containLabel: true
},
series: [
{
center: ["60%", "50%"],
name: "状态",
type: "pie",
radius: "80%",
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 0,
borderColor: "#fff",
borderWidth: 1
},
label: {
show: false,
position: "center"
},
emphasis: {
label: {
show: false,
fontSize: 18,
fontWeight: "bold"
}
},
labelLine: {
show: false
},
data: props.data
}
]
});
</script>
<style lang="less">
.chart {
}
</style>

View File

@ -2,15 +2,19 @@
<div class="dashboard-user">
<div class="header-profile">
<div class="avatar">
<a-avatar size="large" :src="userStore?.userInfo?.avatar"></a-avatar>
<a-avatar v-if="userInfo.avatar" size="large" :src="'/api/basic/file/download?&key=' + userInfo.avatar" style="background-color: #eee"> </a-avatar>
<a-avatar v-else size="large" style="background-color: #00b4f5">
{{ userInfo.username }}
</a-avatar>
</div>
<div class="text">
<div class="left">
<div>
<span>您好{{ userStore?.userInfo?.nickName || userStore?.userInfo?.username }} 欢迎使用 {{ siteInfo.title }} </span>
<span>您好{{ userInfo.nickName || userInfo.username }} 欢迎使用 {{ siteInfo.title }}</span>
</div>
<div>
<a-tag color="green" class="flex-inline"> <fs-icon icon="ion:time-outline" class="mr-5"></fs-icon> {{ now }}</a-tag>
<a-tag color="blue" class="flex-inline"> <fs-icon icon="ion:rocket-outline" class="mr-5"></fs-icon> v{{ version }}</a-tag>
</div>
</div>
</div>
@ -18,7 +22,7 @@
<div>
<tutorial-button class="flex-center">
<a-tag color="blue" class="flex-center">
仅需3步让你的证书永不过期 <fs-icon class="font-size-16 ml-5" icon="mingcute:question-line"></fs-icon
仅需3步全自动申请部署证书<fs-icon class="font-size-16 ml-5" icon="mingcute:question-line"></fs-icon
></a-tag>
</tutorial-button>
<simple-steps></simple-steps>
@ -28,7 +32,7 @@
<div v-if="!settingStore.isComm" class="warning">
<a-alert type="warning" show-icon>
<template #message>
证书和授权为敏感信息不要使用来历不明的在线Certd服务和镜像请务必私有化部署使用保护数据安全认准官方版本发布渠道
证书和授权为敏感信息不要使用来历不明的在线Certd服务和镜像以免泄露请务必私有化部署使用认准官方版本发布渠道
<a class="ml-5 flex-inline" href="https://gitee.com/certd/certd" target="_blank">gitee</a>
<a class="ml-5 flex-inline" href="https://github.com/certd/certd" target="_blank">github</a>
<a class="ml-5 flex-inline" href="https://certd.docmirror.cn" target="_blank">帮助文档</a>
@ -39,22 +43,33 @@
<div class="statistic-data m-20">
<a-row :gutter="20">
<a-col :span="6">
<statistic-card title="提醒"> </statistic-card>
<statistic-card title="证书流水线数量" :count="count.pipelineCount">
<template v-if="count.pipelineCount === 0" #default>
<div class="flex-center flex-1 flex-col">
<div style="font-size: 20px; font-weight: 700">您还没有证书流水线</div>
<fs-button class="mt-10" icon="ion:add-circle-outline" type="primary" @click="goPipeline"></fs-button>
</div>
</template>
<template #footer>
<router-link to="/certd/pipeline" class="flex"><fs-icon icon="ion:add-circle-outline" class="mr-5 fs-16" /> 管理流水线</router-link>
</template>
</statistic-card>
</a-col>
<a-col :span="6">
<statistic-card title="流水线数量"></statistic-card>
<statistic-card title="流水线状态" :footer="false">
<pie-count v-if="count.pipelineStatusCount" :data="count.pipelineStatusCount"></pie-count>
</statistic-card>
</a-col>
<a-col :span="6">
<statistic-card title="最近运行"></statistic-card>
<statistic-card title="最近运行统计" :footer="false">
<day-count v-if="count.runningCount" :data="count.runningCount"></day-count>
</statistic-card>
</a-col>
<a-col :span="6">
<statistic-card title="最近到期证书"></statistic-card>
<statistic-card title="最快到期证书">
<expiring-list v-if="count.expiringList" :data="count.expiringList"></expiring-list>
</statistic-card>
</a-col>
<!-- <a-col :span="12">-->
<!-- <statistic-card title="3步自动部署">-->
<!-- <simple-steps></simple-steps>-->
<!-- </statistic-card>-->
<!-- </a-col>-->
</a-row>
</div>
@ -66,14 +81,16 @@
<a-row :gutter="10">
<a-col v-for="item of pluginGroups.groups.all.plugins" class="plugin-item-col" :span="4">
<a-card>
<div class="plugin-item">
<div class="icon">
<fs-icon :icon="item.icon" class="font-size-16 color-blue" />
<a-tooltip :title="item.desc">
<div class="plugin-item pointer">
<div class="icon">
<fs-icon :icon="item.icon" class="font-size-16 color-blue" />
</div>
<div class="text">
<div class="title">{{ item.title }}</div>
</div>
</div>
<div class="text">
<div class="title">{{ item.title }}</div>
</div>
</div>
</a-tooltip>
</a-card>
</a-col>
</a-row>
@ -85,18 +102,26 @@
<script lang="ts" setup>
import { FsIcon } from "@fast-crud/fast-crud";
import SimpleSteps from "./simple-steps.vue";
defineOptions({
name: "DashboardUser"
});
import { useUserStore } from "/@/store/modules/user";
import { computed, onMounted, Ref, ref } from "vue";
import { computed, ComputedRef, onMounted, Ref, ref } from "vue";
import dayjs from "dayjs";
import StatisticCard from "/@/views/framework/home/dashboard/statistic-card.vue";
import * as pluginApi from "/@/views/certd/pipeline/api.plugin";
import { PluginGroups } from "/@/views/certd/pipeline/pipeline/type";
import TutorialButton from "/@/components/tutorial/index.vue";
import DayCount from "./charts/day-count.vue";
import PieCount from "./charts/pie-count.vue";
import ExpiringList from "./charts/expiring-list.vue";
import { useSettingStore } from "/@/store/modules/settings";
import { SiteInfo } from "/@/api/modules/api.basic";
import { UserInfoRes } from "/@/api/modules/api.user";
import { GetStatisticCount } from "/@/views/framework/home/dashboard/api";
import { useRouter } from "vue-router";
defineOptions({
name: "DashboardUser"
});
const version = ref(import.meta.env.VITE_APP_VERSION);
const settingStore = useSettingStore();
@ -105,18 +130,52 @@ const siteInfo: Ref<SiteInfo> = computed(() => {
});
const userStore = useUserStore();
const userInfo: ComputedRef<UserInfoRes> = computed(() => {
return userStore.getUserInfo;
});
const now = computed(() => {
return dayjs().format("YYYY-MM-DD HH:mm:ss");
});
const router = useRouter();
function goPipeline() {
router.push({ path: "/certd/pipeline" });
}
async function getPluginGroups() {
const count: any = ref({});
function transformStatusCount() {
const data = count.value.pipelineStatusCount;
const sorted = [
{ name: "success", label: "成功" },
{ name: "start", label: "运行中" },
{ name: "error", label: "失败" },
{ name: "canceled", label: "已取消" },
{ name: null, label: "未执行" }
];
const result = [];
for (const item of sorted) {
const find = data.find((v: any) => v.name === item.name);
if (find) {
result.push({ name: item.label, value: find.value });
} else {
result.push({ name: item.label, value: 0 });
}
}
count.value.pipelineStatusCount = result;
}
async function loadCount() {
count.value = await GetStatisticCount();
transformStatusCount();
}
async function loadPluginGroups() {
const groups = await pluginApi.GetGroups({});
return new PluginGroups(groups);
pluginGroups.value = new PluginGroups(groups);
}
const pluginGroups = ref();
onMounted(async () => {
pluginGroups.value = await getPluginGroups();
await loadCount();
await loadPluginGroups();
});
</script>

View File

@ -8,12 +8,12 @@
</div>
<div class="content">
<div v-if="!slots.default" class="statistic">
<div class="value">80</div>
<div class="value">{{ count }}</div>
</div>
<slot></slot>
</div>
<div class="footer">
<div class="icon-text"><fs-icon icon="ion:settings-outline" />管理流水线</div>
<div v-if="slots.footer" class="footer">
<slot name="footer"></slot>
</div>
</div>
</a-card>
@ -23,6 +23,7 @@
import { FsIcon } from "@fast-crud/fast-crud";
const props = defineProps<{
title: string;
count?: number;
}>();
const slots = defineSlots();
</script>
@ -41,7 +42,7 @@ const slots = defineSlots();
.data-item {
display: flex;
flex-direction: column;
height: 180px;
.header {
display: flex;
justify-content: space-between;
@ -51,7 +52,7 @@ const slots = defineSlots();
.content {
display: flex;
flex-direction: column;
height: 100px;
flex: 1;
.statistic {
height: 100%;
display: flex;
@ -64,6 +65,9 @@ const slots = defineSlots();
color: #2c254e;
}
}
x-vue-echarts {
}
}
.footer {
color: #8077a4;

View File

@ -0,0 +1,68 @@
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
import { BaseController, Constants } from '@certd/lib-server';
import { UserService } from '../../modules/sys/authority/service/user-service.js';
import { RoleService } from '../../modules/sys/authority/service/role-service.js';
import { PipelineService } from '../../modules/pipeline/service/pipeline-service.js';
import { HistoryService } from '../../modules/pipeline/service/history-service.js';
export type ChartItem = {
name: string;
value: number;
};
export type UserStatisticCount = {
pipelineCount?: number;
pipelineStatusCount?: ChartItem[];
runningCount: ChartItem[];
expiringList: any[];
};
/**
*/
@Provide()
@Controller('/api/statistic/')
export class StatisticController extends BaseController {
@Inject()
userService: UserService;
@Inject()
roleService: RoleService;
@Inject()
pipelineService: PipelineService;
@Inject()
historyService: HistoryService;
@Post('/count', { summary: Constants.per.authOnly })
public async count() {
const pipelineCount = await this.pipelineService.count({ userId: this.getUserId() });
let pipelineStatusCount = await this.pipelineService.statusCount({ userId: this.getUserId() });
pipelineStatusCount = pipelineStatusCount.map(item => {
return {
name: item.status,
value: item.count,
};
});
const historyCount = await this.historyService.dayCount({ userId: this.getUserId(), days: 7 });
const runningCount = historyCount.map(item => {
return {
name: item.date,
value: item.count,
};
});
const expiringList = await this.pipelineService.latestExpiringList({ userId: this.getUserId(), count: 5 });
const count: UserStatisticCount = {
pipelineCount,
pipelineStatusCount,
runningCount,
expiringList,
};
return this.ok(count);
}
@Post('/changePassword', { summary: Constants.per.authOnly })
public async changePassword(@Body(ALL) body: any) {
const userId = this.getUserId();
await this.userService.changePassword(userId, body);
return this.ok({});
}
}

View File

@ -1,6 +1,6 @@
import { Config, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { In, Repository } from 'typeorm';
import { In, MoreThan, Repository } from 'typeorm';
import { BaseService, PageReq } from '@certd/lib-server';
import { HistoryEntity } from '../entity/history.js';
import { PipelineEntity } from '../entity/pipeline.js';
@ -9,7 +9,7 @@ import { HistoryLogService } from './history-log-service.js';
import { FileItem, Pipeline, RunnableCollection } from '@certd/pipeline';
import { FileStore } from '@certd/pipeline';
import { logger } from '@certd/pipeline';
import dayjs from 'dayjs';
/**
*
*/
@ -174,4 +174,21 @@ export class HistoryService extends BaseService<HistoryEntity> {
logger.error('删除文件失败', e);
}
}
async dayCount(param: { days: number; userId: any }) {
const todayEnd = dayjs().endOf('day');
const result = await this.getRepository()
.createQueryBuilder('main')
.select('date(main.createTime) AS date') // 将UNIX时间戳转换为日期
.addSelect('COUNT(*) AS count')
.where({
// 0点
userId: param.userId,
createTime: MoreThan(todayEnd.add(-param.days, 'day').toDate()),
})
.groupBy('date')
.getRawMany();
return result;
}
}

View File

@ -71,11 +71,18 @@ export class PipelineService extends BaseService<PipelineEntity> {
async page(pageReq: PageReq<PipelineEntity>) {
const result = await super.page(pageReq);
await this.fillLastVars(result.records);
return result;
}
private async fillLastVars(records: PipelineEntity[]) {
const pipelineIds: number[] = [];
const recordMap = {};
for (const record of result.records) {
for (const record of records) {
pipelineIds.push(record.id);
recordMap[record.id] = record;
record.title = record.title + '';
}
if (pipelineIds?.length > 0) {
const vars = await this.storageService.findPipelineVars(pipelineIds);
@ -87,8 +94,6 @@ export class PipelineService extends BaseService<PipelineEntity> {
}
}
}
return result;
}
public async registerTriggerById(pipelineId) {
@ -467,4 +472,48 @@ export class PipelineService extends BaseService<PipelineEntity> {
logEntity.logs = JSON.stringify(history.logs);
await this.historyLogService.addOrUpdate(logEntity);
}
async count(param: { userId: any }) {
const count = await this.repository.count({
where: {
userId: param.userId,
},
});
return count;
}
async statusCount(param: { userId: any }) {
const statusCount = await this.repository
.createQueryBuilder()
.select('status')
.addSelect('count(1)', 'count')
.where({
userId: param.userId,
})
.groupBy('status')
.getRawMany();
return statusCount;
}
async latestExpiringList({ userId }: any) {
let list = await this.repository.find({
select: {
id: true,
title: true,
status: true,
},
where: {
userId,
},
});
await this.fillLastVars(list);
list = list.filter(item => {
return item.lastVars?.certExpiresTime != null;
});
list = list.sort((a, b) => {
return a.lastVars.certExpiresTime - b.lastVars.certExpiresTime;
});
return list.slice(0, 5);
}
}