Merge pull request #2132 from semaphoreui/schedule

schedule
This commit is contained in:
Denis Gukov 2024-06-24 18:31:45 +05:00 committed by GitHub
commit 6b945e8c4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 33837 additions and 33253 deletions

View File

@ -42,6 +42,17 @@ func GetSchedule(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, schedule)
}
func GetProjectSchedules(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
tplSchedules, err := helpers.Store(r).GetProjectSchedules(project.ID)
if err != nil {
helpers.WriteError(w, err)
return
}
helpers.WriteJSON(w, http.StatusOK, tplSchedules)
}
func GetTemplateSchedules(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
templateID, err := helpers.GetIntParam("template_id", w, r)

View File

@ -178,6 +178,7 @@ func Route() *mux.Router {
projectUserAPI.Path("/templates").HandlerFunc(projects.GetTemplates).Methods("GET", "HEAD")
projectUserAPI.Path("/templates").HandlerFunc(projects.AddTemplate).Methods("POST")
projectUserAPI.Path("/schedules").HandlerFunc(projects.GetProjectSchedules).Methods("GET", "HEAD")
projectUserAPI.Path("/schedules").HandlerFunc(projects.AddSchedule).Methods("POST")
projectUserAPI.Path("/schedules/validate").HandlerFunc(projects.ValidateScheduleCronFormat).Methods("POST")

View File

@ -8,3 +8,8 @@ type Schedule struct {
RepositoryID *int `db:"repository_id" json:"repository_id"`
LastCommitHash *string `db:"last_commit_hash" json:"-"`
}
type ScheduleWithTpl struct {
Schedule
TemplateName string `db:"tpl_name" json:"tpl_name"`
}

View File

@ -197,6 +197,7 @@ type Store interface {
DeleteTemplate(projectID int, templateID int) error
GetSchedules() ([]Schedule, error)
GetProjectSchedules(projectID int) ([]ScheduleWithTpl, error)
GetTemplateSchedules(projectID int, templateID int) ([]Schedule, error)
CreateSchedule(schedule Schedule) (Schedule, error)
UpdateSchedule(schedule Schedule) error

View File

@ -16,7 +16,7 @@ func (d *BoltDb) GetSchedules() (schedules []db.Schedule, err error) {
for _, proj := range allProjects {
var projSchedules []db.Schedule
projSchedules, err = d.GetProjectSchedules(proj.ID)
projSchedules, err = d.getProjectSchedules(proj.ID)
if err != nil {
return
}
@ -26,15 +26,23 @@ func (d *BoltDb) GetSchedules() (schedules []db.Schedule, err error) {
return
}
func (d *BoltDb) GetProjectSchedules(projectID int) (schedules []db.Schedule, err error) {
func (d *BoltDb) getProjectSchedules(projectID int) (schedules []db.Schedule, err error) {
err = d.getObjects(projectID, db.ScheduleProps, db.RetrieveQueryParams{}, nil, &schedules)
return
}
func (d *BoltDb) GetProjectSchedules(projectID int) (schedules []db.ScheduleWithTpl, err error) {
err = d.getObjects(projectID, db.ScheduleProps, db.RetrieveQueryParams{}, func(referringObj interface{}) bool {
s := referringObj.(db.ScheduleWithTpl)
return s.RepositoryID == nil
}, &schedules)
return
}
func (d *BoltDb) GetTemplateSchedules(projectID int, templateID int) (schedules []db.Schedule, err error) {
schedules = make([]db.Schedule, 0)
projSchedules, err := d.GetProjectSchedules(projectID)
projSchedules, err := d.getProjectSchedules(projectID)
if err != nil {
return
}

View File

@ -72,6 +72,15 @@ func (d *SqlDb) GetSchedules() (schedules []db.Schedule, err error) {
return
}
func (d *SqlDb) GetProjectSchedules(projectID int) (schedules []db.ScheduleWithTpl, err error) {
_, err = d.selectAll(&schedules,
"SELECT ps.*, pt.name as tpl_name FROM project__schedule ps "+
"JOIN project__template pt ON pt.id = ps.template_id "+
"WHERE ps.repository_id IS NULL AND ps.project_id=?",
projectID)
return
}
func (d *SqlDb) GetTemplateSchedules(projectID int, templateID int) (schedules []db.Schedule, err error) {
_, err = d.selectAll(&schedules,
"select * from project__schedule where project_id=? and template_id=?",

66289
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"ansi-to-html": "^0.7.2",
"axios": "^0.28.0",
"core-js": "^3.23.2",
"cron-parser": "^4.9.0",
"moment": "^2.29.4",
"vue": "^2.6.14",
"vue-codemirror": "^4.0.6",

View File

@ -216,6 +216,20 @@
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="project.type === ''"
key="schedule"
:to="`/project/${projectId}/schedule`"
>
<v-list-item-icon>
<v-icon>mdi-clock-outline</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t('Schedule') }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="project.type === ''"
key="inventory"
@ -547,7 +561,7 @@
.v-data-table-header {
}
.theme--light.v-data-table > .v-data-table__wrapper > table > thead > tr:last-child > th {
.v-data-table > .v-data-table__wrapper > table > thead > tr:last-child > th {
text-transform: uppercase;
white-space: nowrap;
}

View File

@ -0,0 +1,432 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="templates && item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}
</v-alert>
<!-- <v-text-field-->
<!-- v-model="item.name"-->
<!-- :label="$t('Name')"-->
<!-- :rules="[v => !!v || $t('name_required')]"-->
<!-- required-->
<!-- :disabled="formSaving"-->
<!-- class="mb-4"-->
<!-- ></v-text-field>-->
<v-select
v-model="item.template_id"
:label="$t('Template')"
:items="templates"
item-value="id"
:item-text="(itm) => itm.name"
:rules="[v => !!v || $t('template_required')]"
required
:disabled="formSaving"
/>
<v-text-field
v-model="item.cron_format"
:label="$t('Cron')"
:rules="[v => !!v || $t('Cron required')]"
required
:disabled="formSaving"
@input="refreshCheckboxes()"
></v-text-field>
<div class="mb-4" style="color: limegreen; font-weight: bold;">
Next run {{ nextRunTime() | formatDate }}.
</div>
<div>
<v-select
v-model="timing"
:label="$t('Timing')"
:items="TIMINGS"
item-value="id"
item-text="title"
:rules="[v => !!v || $t('template_required')]"
required
:disabled="formSaving"
@change="refreshCron()"
/>
<div v-if="['yearly'].includes(timing)">
<div>Months</div>
<div class="d-flex flex-wrap">
<v-checkbox
class="mr-2 mt-0 ScheduleCheckbox"
v-for="m in MONTHS" :key="m.id"
:value="m.id"
:label="m.title"
v-model="months"
color="white"
:class="{'ScheduleCheckbox--active': months.includes(m.id)}"
@change="refreshCron()"
></v-checkbox>
</div>
</div>
<div v-if="['weekly'].includes(timing)">
<div class="mt-4">Weekdays</div>
<div class="d-flex flex-wrap">
<v-checkbox
class="mr-2 mt-0 ScheduleCheckbox"
v-for="d in WEEKDAYS" :key="d.id"
:value="d.id"
:label="d.title"
v-model="weekdays"
color="white"
:class="{'ScheduleCheckbox--active': weekdays.includes(d.id)}"
@change="refreshCron()"
></v-checkbox>
</div>
</div>
<div v-if="['yearly', 'monthly'].includes(timing)">
<div class="mt-4">Days</div>
<div class="d-flex flex-wrap">
<v-checkbox
class="mr-2 mt-0 ScheduleCheckbox"
v-for="d in 31"
:key="d"
:value="d"
:label="`${d}`"
v-model="days"
color="white"
:class="{'ScheduleCheckbox--active': days.includes(d)}"
@change="refreshCron()"
></v-checkbox>
</div>
</div>
<div v-if="['yearly', 'monthly', 'weekly', 'daily'].includes(timing)">
<div class="mt-4">Hours</div>
<div class="d-flex flex-wrap">
<v-checkbox
class="mr-2 mt-0 ScheduleCheckbox"
v-for="h in 24"
:key="h - 1"
:value="h - 1"
:label="`${h - 1}`"
v-model="hours"
color="white"
:class="{'ScheduleCheckbox--active': hours.includes(h - 1)}"
@change="refreshCron()"
></v-checkbox>
</div>
</div>
<div>
<div class="mt-4">Minutes</div>
<div class="d-flex flex-wrap">
<v-checkbox
class="mr-2 mt-0 ScheduleCheckbox"
v-for="m in MINUTES"
:key="m.id"
:value="m.id"
:label="m.title"
v-model="minutes"
color="white"
:class="{'ScheduleCheckbox--active': minutes.includes(m.id)}"
@change="refreshCron()"
></v-checkbox>
</div>
</div>
</div>
</v-form>
</template>
<style lang="scss">
.ScheduleCheckbox {
.v-input__slot {
padding: 4px 6px;
font-weight: bold;
border-radius: 6px;
}
.v-messages {
display: none;
}
&.theme--light {
.v-input__slot {
background: #e4e4e4;
}
}
&.theme--dark {
.v-input__slot {
background: gray;
}
}
}
.ScheduleCheckbox--active {
.v-input__slot {
background: #4caf50 !important;
}
.v-label {
color: white;
}
}
</style>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
const parser = require('cron-parser');
const MONTHS = [{
id: 1,
title: 'Jan',
}, {
id: 2,
title: 'Feb',
}, {
id: 3,
title: 'March',
}, {
id: 4,
title: 'March',
}, {
id: 5,
title: 'March',
}, {
id: 6,
title: 'March',
}, {
id: 7,
title: 'March',
}];
const TIMINGS = [{
id: 'yearly',
title: 'Yearly',
}, {
id: 'monthly',
title: 'Monthly',
}, {
id: 'weekly',
title: 'Weekly',
}, {
id: 'daily',
title: 'Daily',
}, {
id: 'hourly',
title: 'Hourly',
}];
const WEEKDAYS = [{
id: 0,
title: 'Sunday',
}, {
id: 1,
title: 'Monday',
}, {
id: 2,
title: 'Tuesday',
}, {
id: 3,
title: 'Wednesday',
}, {
id: 4,
title: 'Thursday',
}, {
id: 5,
title: 'Friday',
}, {
id: 6,
title: 'Saturday',
}];
const MINUTES = [
{ id: 0, title: ':00' },
{ id: 5, title: ':05' },
{ id: 10, title: ':10' },
{ id: 15, title: ':15' },
{ id: 20, title: ':20' },
{ id: 25, title: ':25' },
{ id: 30, title: ':30' },
{ id: 35, title: ':35' },
{ id: 40, title: ':40' },
{ id: 45, title: ':45' },
{ id: 50, title: ':50' },
{ id: 55, title: ':55' },
];
export default {
mixins: [ItemFormBase],
data() {
return {
templates: null,
timing: 'hourly',
TIMINGS,
MONTHS,
WEEKDAYS,
MINUTES,
minutes: [],
hours: [],
days: [],
months: [],
weekdays: [],
};
},
async created() {
this.templates = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/templates`,
responseType: 'json',
})).data;
},
methods: {
nextRunTime() {
return parser.parseExpression(this.item.cron_format).next();
},
refreshCheckboxes() {
const fields = JSON.parse(
JSON.stringify(parser.parseExpression(this.item.cron_format).fields),
);
if (this.isHourly(this.item.cron_format)) {
this.minutes = fields.minute;
this.timing = 'hourly';
} else {
this.minutes = [];
}
if (this.isDaily(this.item.cron_format)) {
this.hours = fields.hour;
this.timing = 'daily';
} else {
this.hours = [];
}
if (this.isWeekly(this.item.cron_format)) {
this.weekdays = fields.dayOfWeek;
this.timing = 'weekly';
} else {
this.months = [];
this.weekdays = [];
}
if (this.isMonthly(this.item.cron_format)) {
this.days = fields.dayOfMonth;
this.timing = 'monthly';
} else {
this.months = [];
this.weekdays = [];
}
if (this.isYearly(this.item.cron_format)) {
this.months = fields.month;
this.timing = 'yearly';
}
},
afterLoadData() {
if (this.isNew) {
this.item.cron_format = '* * * * *';
}
this.refreshCheckboxes();
},
isWeekly(s) {
return /^\S+\s\S+\s\S+\s\S+\s[^*]\S*$/.test(s);
},
isYearly(s) {
return /^\S+\s\S+\s\S+\s[^*]\S*\s\S+$/.test(s);
},
isMonthly(s) {
return /^\S+\s\S+\s[^*]\S*\s\S+\s\S+$/.test(s);
},
isDaily(s) {
return /^\S+\s[^*]\S*\s\S+\s\S+\s\S+$/.test(s);
},
isHourly(s) {
return /^[^*]\S*\s\S+\s\S+\s\S+\s\S+$/.test(s);
},
refreshCron() {
const fields = JSON.parse(JSON.stringify(parser.parseExpression('* * * * *').fields));
switch (this.timing) {
case 'hourly':
this.months = [];
this.weekdays = [];
this.days = [];
this.hours = [];
break;
case 'daily':
this.days = [];
this.months = [];
this.weekdays = [];
break;
case 'monthly':
this.months = [];
this.weekdays = [];
break;
case 'weekly':
this.months = [];
this.days = [];
break;
default:
break;
}
if (this.months.length > 0) {
fields.month = this.months;
}
if (this.weekdays.length > 0) {
fields.dayOfWeek = this.weekdays;
}
if (this.days.length > 0) {
fields.dayOfMonth = this.days;
}
if (this.hours.length > 0) {
fields.hour = this.hours;
}
if (this.minutes.length > 0) {
fields.minute = this.minutes;
}
this.item.cron_format = parser.fieldsToExpression(fields).stringify();
},
getItemsUrl() {
return `/api/project/${this.projectId}/schedules`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/schedules/${this.itemId}`;
},
},
};
</script>

View File

@ -1,86 +0,0 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
:color="(formError || '').includes('already activated') ? 'warning' : 'error'"
>{{ formError }}
</v-alert>
<v-text-field
v-model="item.key"
label="Subscription Key"
:rules="[v => !!v || $t('key_required')]"
required
:disabled="formSaving"
></v-text-field>
<v-list v-if="item.plan">
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Plan</v-list-item-title>
<v-list-item-subtitle>{{ item.plan }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Expires at</v-list-item-title>
<v-list-item-subtitle>{{ item.expiresAt }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Users</v-list-item-title>
<v-list-item-subtitle>{{ item.users }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-alert color="info" v-else>There is no active subscription.</v-alert>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
data() {
return {};
},
methods: {
async loadData() {
this.item = (await axios({
method: 'get',
url: '/api/subscription',
responseType: 'json',
})).data;
},
async afterSave() {
await this.loadData();
},
getItemsUrl() {
return '/api/subscription';
},
getSingleItemUrl() {
return '/api/subscription';
},
getRequestOptions() {
return {
method: 'post',
url: '/api/subscription',
};
},
},
};
</script>

View File

@ -238,34 +238,16 @@
dense
></v-select>
<v-row>
<v-col cols="5" class="pr-1">
<v-text-field
style="font-size: 14px"
v-model="cronFormat"
:label="$t('cron')"
:disabled="formSaving"
placeholder="* * * * *"
v-if="schedules == null || schedules.length <= 1"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-checkbox
class="mt-0"
:label="$t('iWantToRunATaskByTheCronOnlyForForNewCommitsOfSome')"
v-model="cronRepositoryIdVisible"
/>
<v-row v-if="cronRepositoryIdVisible" class="mb-2">
<v-col cols="7">
<a
v-if="!cronRepositoryIdVisible && cronRepositoryId == null"
@click="cronRepositoryIdVisible = true"
class="text-caption d-block"
style="line-height: 1.1;"
>
{{ $t('iWantToRunATaskByTheCronOnlyForForNewCommitsOfSome') }}
</a>
<v-select
style="font-size: 14px"
v-if="cronRepositoryIdVisible || cronRepositoryId != null"
v-model="cronRepositoryId"
:label="$t('repository2')"
:placeholder="$t('cronChecksNewCommitBeforeRun')"
@ -278,16 +260,24 @@
dense
hide-details
></v-select>
</v-col>
<v-col cols="5">
<v-select
v-model="cronFormat"
:label="$t('Interval')"
:placeholder="$t('New commit check interval')"
item-value="cron"
item-text="title"
:items="cronFormats"
:disabled="formSaving"
outlined
dense
hide-details
/>
</v-col>
</v-row>
<small class="mt-1 mb-4 d-block">
{{ $t('readThe') }}
<a target="_blank" href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format">{{ $t('docs') }}</a>
{{ $t('toLearnMoreAboutCron') }}
</small>
<v-checkbox
class="mt-0"
:label="$t('suppressSuccessAlerts')"
@ -356,6 +346,22 @@ export default {
data() {
return {
cronFormats: [{
cron: '* * * * *',
title: '1 minute',
}, {
cron: '*/5 * * * *',
title: '5 minutes',
}, {
cron: '*/10 * * * *',
title: '10 minutes',
}, {
cron: '@hourly',
title: '1 hour',
}, {
cron: '@daily',
title: '24 hours',
}],
itemTypeIndex: 0,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
@ -517,9 +523,13 @@ export default {
responseType: 'json',
})).data;
if (this.schedules.length === 1) {
this.cronFormat = this.schedules[0].cron_format;
this.cronRepositoryId = this.schedules[0].repository_id;
if (this.schedules.length > 0) {
const schedule = this.schedules.find((s) => s.repository_id != null);
if (schedule != null) {
this.cronFormat = schedule.cron_format;
this.cronRepositoryId = schedule.repository_id;
this.cronRepositoryIdVisible = this.cronRepositoryId != null;
}
}
this.itemTypeIndex = Object.keys(TEMPLATE_TYPE_ICONS).indexOf(this.item.type);
@ -566,7 +576,7 @@ export default {
}
} else if (this.schedules.length > 1) {
// do nothing
} else if (this.cronFormat == null || this.cronFormat === '') {
} else if (this.cronFormat == null || this.cronFormat === '' || !this.cronRepositoryIdVisible) {
// drop schedule
await axios({
method: 'delete',

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import Schedule from '../views/project/Schedule.vue';
import History from '../views/project/History.vue';
import Activity from '../views/project/Activity.vue';
import Settings from '../views/project/Settings.vue';
@ -14,7 +15,6 @@ import Users from '../views/Users.vue';
import Auth from '../views/Auth.vue';
import New from '../views/project/New.vue';
import Integrations from '../views/project/Integrations.vue';
import IntegrationExtractor from '../views/project/IntegrationExtractor.vue';
Vue.use(VueRouter);
@ -36,6 +36,10 @@ const routes = [
path: '/project/:projectId/activity',
component: Activity,
},
{
path: '/project/:projectId/schedule',
component: Schedule,
},
{
path: '/project/:projectId/settings',
component: Settings,

View File

@ -0,0 +1,141 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<EditDialog
v-model="editDialog"
:save-button-text="$t('save')"
:title="$t('Edit Schedule')"
:max-width="500"
@save="loadItems"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<ScheduleForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<ObjectRefsDialog
object-title="schedule"
:object-refs="itemRefs"
:project-id="projectId"
v-model="itemRefsDialog"
/>
<YesNoDialog
:title="$t('Delete Schedule')"
:text="$t('askDeleteEnv')"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat >
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>{{ $t('Schedule') }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
v-if="can(USER_PERMISSIONS.manageProjectResources)"
>{{ $t('New Schedule') }}
</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.tpl_name="{ item }">
<div class="d-flex">
<router-link :to="
'/project/' + item.project_id +
'/templates/' + item.template_id"
>{{ item.tpl_name }}
</router-link>
</div>
</template>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-btn
icon
class="mr-1"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
class="mr-1"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</div>
</template>
<template v-slot:expanded-item="{ headers, item }">
<td
:colspan="headers.length"
v-if="openedItems.some((template) => template.id === item.id)"
>
<TaskList
style="border: 1px solid lightgray; border-radius: 6px; margin: 10px 0;"
:template="item"
:limit="5"
:hide-footer="true"
/>
</td>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import ScheduleForm from '@/components/ScheduleForm.vue';
import TaskList from '@/components/TaskList.vue';
export default {
components: { TaskList, ScheduleForm },
mixins: [ItemListPageBase],
data() {
return {
openedItems: [],
};
},
methods: {
getHeaders() {
return [{
text: this.$i18n.t('Cron'),
value: 'cron_format',
}, {
text: this.$i18n.t('Template'),
value: 'tpl_name',
width: '100%',
}, {
text: this.$i18n.t('actions'),
value: 'actions',
sortable: false,
}];
},
getItemsUrl() {
return `/api/project/${this.projectId}/schedules`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/schedules/${this.itemId}`;
},
getEventName() {
return 'i-schedule';
},
},
};
</script>