feat: Enhance background job settings UI and functionality
Some checks failed
build.yaml / feat: Enhance background job settings UI and functionality (push) Failing after 0s

- Updated BackgroundJob.vue to improve the display of background job statuses, including missing cross-references and current job mode.
- Added auto-refresh functionality for background job status.
- Introduced success toast notifications for successful status refreshes.
- Modified the XML serialization process in DatasetXmlSerializer for better caching and performance.
- Implemented a new RuleProvider for managing custom validation rules.
- Improved error handling in routes for loading background job settings.
- Enhanced ClamScan configuration with socket support for virus scanning.
- Refactored dayjs utility to streamline locale management.
This commit is contained in:
Kaimbacher 2025-10-14 12:19:09 +02:00
commit b5bbe26ec2
27 changed files with 1221 additions and 603 deletions

View file

@ -1,21 +1,41 @@
<template>
<NcSettingsSection :name="t('settings', 'Background jobs')" :description="t(
'settings',
`For the server to work properly, it\'s important to configure background jobs correctly. Cron is the recommended setting. Please see the documentation for more information.`,
)" :doc-url="backgroundJobsDocUrl">
<template v-if="lastCron !== 0">
<NcNoteCard v-if="oldExecution" type="danger">
{{ t('settings', `Last job execution ran {time}. Something seems wrong.`, {
time: relativeTime,
timestamp: lastCron
}) }}
<NcSettingsSection
:name="t('settings', 'Background jobs')"
:description="
t(
'settings',
`For the server to work properly, it's important to configure background jobs correctly. Cron is the recommended setting. Please see the documentation for more information.`,
)
"
:doc-url="backgroundJobsDocUrl"
>
<template v-if="lastCronTimestamp">
<NcNoteCard v-if="isExecutionTooOld" type="danger">
{{
t(
'settings',
`Last job execution ran {time}. Something seems wrong.
Timestamp of last cron: {timestamp} `,
{
time: relativeTime,
timestamp: lastCronTimestamp,
},
)
}}
</NcNoteCard>
<!-- <NcNoteCard v-else-if="longExecutionCron" type="warning">
{{ t('settings', `Some jobs have not been executed since {maxAgeRelativeTime}. Please consider increasing the execution frequency.`, {maxAgeRelativeTime}) }}
</NcNoteCard> -->
<NcNoteCard v-else-if="isLongExecutionCron" type="warning">
{{
t(
'settings',
`Some jobs have not been executed since {maxAgeRelativeTime}. Please consider increasing the execution
frequency.`,
{
maxAgeRelativeTime,
},
)
}}
</NcNoteCard>
<NcNoteCard v-else type="success">
{{ t('settings', 'Last job ran {relativeTime}.', { relativeTime }) }}
@ -23,147 +43,180 @@
</template>
<NcNoteCard v-else type="danger">
'Background job did not run yet!'
{{ t('settings', 'Background job did not run yet!') }}
</NcNoteCard>
<!-- Missing Cross References Warning -->
<NcNoteCard v-if="missingCrossReferencesCount >= 1" type="warning">
{{
t('settings', 'Found {count} missing dataset cross-reference(s). You can fix this by running: node ace detect:missing-cross-references --fix', {
count: missingCrossReferencesCount,
})
}}
</NcNoteCard>
<!-- Background Jobs Status Display -->
<div class="background-jobs-mode">
<h3>{{ t('settings', 'Background Jobs Mode') }}</h3>
<div class="current-mode">
<span class="mode-label">{{ t('settings', 'Current mode:') }}</span>
<span class="mode-value">{{ getCurrentModeLabel() }}</span>
</div>
<!-- <div class="mode-description" v-if="backgroundJobsMode === 'cron'" v-html="cronLabel"></div> -->
</div>
<!-- Refresh Button -->
<div class="actions">
<button type="button" class="primary" @click="refreshStatus" :disabled="isRefreshing">
{{ isRefreshing ? t('settings', 'Refreshing...') : t('settings', 'Refresh Status') }}
</button>
</div>
</NcSettingsSection>
</template>
<script lang="ts">
import { usePage } from '@inertiajs/vue3';
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { usePage, router } from '@inertiajs/vue3';
import { loadState } from '@/utils/initialState';
import { showError } from '@/utils/toast';
// import { generateOcsUrl } from '@nextcloud/router';
// import { confirmPassword } from '@nextcloud/password-confirmation';
import axios from 'axios';
import { showError, showSuccess } from '@/utils/toast';
import dayjs from '@/utils/dayjs';
import NcNoteCard from '@/Components/NCNoteCard.vue';
import NcSettingsSection from '@/Components/NcSettingsSection.vue';
import { translate as t } from '@/utils/tethyscloud-l10n';
// import { useLocaleStore } from '@/Stores/locale';
// import '@nextcloud/password-confirmation/dist/style.css';
// Props and reactive data
const cronMaxAge =ref<number>(loadState('settings', 'cronMaxAge', 1758824778));
const backgroundJobsMode = ref<string>(loadState('settings', 'backgroundJobsMode', 'cron'));
const cliBasedCronPossible = ref<boolean>(loadState('settings', 'cliBasedCronPossible', true));
const cliBasedCronUser = ref<string>(loadState('settings', 'cliBasedCronUser', 'www-data'));
const backgroundJobsDocUrl = ref<string>(loadState('settings', 'backgroundJobsDocUrl', ''));
const isRefreshing = ref<boolean>(false);
// const lastCron: number = 1723807502; //loadState('settings', 'lastCron'); //1723788607
const cronMaxAge: number = 1724046901;//loadState('settings', 'cronMaxAge', 0); //''
const backgroundJobsMode: string = loadState('settings', 'backgroundJobsMode', 'cron'); //cron
const cliBasedCronPossible = loadState('settings', 'cliBasedCronPossible', true); //true
const cliBasedCronUser = loadState('settings', 'cliBasedCronUser', 'www-data'); //www-data
const backgroundJobsDocUrl: string = loadState('settings', 'backgroundJobsDocUrl'); //https://docs.nextcloud.com/server/29/go.php?to=admin-background-jobs
// Use reactive page reference
const page = usePage();
// await loadTranslations('settings');
// Auto-refresh timer
let refreshTimer: NodeJS.Timeout | null = null;
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
export default {
name: 'BackgroundJob',
// Computed properties
const missingCrossReferencesCount = computed((): number => {
const count = page.props.missingCrossReferencesCount as string | number;
return typeof count === 'string' ? parseInt(count) || 0 : count || 0;
});
components: {
NcSettingsSection,
NcNoteCard,
},
const lastCronTimestamp = computed((): number => {
const lastCron = page.props.lastCron as string | number;
return typeof lastCron === 'string' ? parseInt(lastCron) || 0 : lastCron || 0;
});
data() {
return {
// lastCron: 0,
cronMaxAge: cronMaxAge,
backgroundJobsMode: backgroundJobsMode,
cliBasedCronPossible: cliBasedCronPossible,
cliBasedCronUser: cliBasedCronUser,
backgroundJobsDocUrl: backgroundJobsDocUrl,
// relativeTime: dayjs(this.lastCron * 1000).fromNow(),
// maxAgeRelativeTime: dayjs(cronMaxAge * 1000).fromNow(),
t: t,
};
},
computed: {
lastCron(): number {
return usePage().props.lastCron as number;
const relativeTime = computed((): string => {
if (!lastCronTimestamp.value) return '';
// Reference currentTime.value to make this reactive to time changes
let intermValue = page.props.lastCron as string | number;
intermValue = typeof intermValue === 'string' ? parseInt(intermValue) || 0 : intermValue || 0;
return dayjs.unix(intermValue).fromNow();
});
},
relativeTime() {
return dayjs.unix(this.lastCron).fromNow(); // Calculate relative time for lastCron
},
maxAgeRelativeTime() {
return dayjs.unix(this.cronMaxAge).fromNow(); // Calculate relative time for cronMaxAge
},
cronLabel() {
let desc = 'Use system cron service to call the cron.php file every 5 minutes.';
if (this.cliBasedCronPossible) {
desc +=
'<br>' +
'The cron.php needs to be executed by the system account "{user}".', { user: this.cliBasedCronUser };
} else {
desc +=
'<br>' +
const maxAgeRelativeTime = computed((): string => {
return dayjs.unix(cronMaxAge).fromNow();
});
'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.',
{
linkstart:
'<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
linkend: '</a>',
}
}
return desc;
},
oldExecution() {
return (dayjs().unix() - this.lastCron) > 600; // older than 10 minutes
},
const cronLabel = computed((): string => {
let desc = t('settings', 'Use system cron service to call the cron.php file every 5 minutes.');
longExecutionCron() {
//type of cron job and greater than 24h
// let test = dayjs.unix(this.cronMaxAge).format('YYYY-MM-DD HH:mm:ss');
return (dayjs().unix() - this.cronMaxAge) > 24 * 3600 && this.backgroundJobsMode === 'cron';
},
},
methods: {
async onBackgroundJobModeChanged(backgroundJobsMode: string) {
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
appId: 'core',
key: 'backgroundjobs_mode',
if (cliBasedCronPossible.value) {
desc +=
'<br>' +
t('settings', 'The cron.php needs to be executed by the system account "{user}".', {
user: cliBasedCronUser.value,
});
// await confirmPassword();
try {
const { data } = await axios.post(url, {
value: backgroundJobsMode,
});
this.handleResponse({
status: data.ocs?.meta?.status,
});
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update background job mode'),
error: e,
});
}
},
async handleResponse({ status, errorMessage, error }) {
if (status === 'ok') {
await this.deleteError();
} else {
showError(errorMessage);
console.error(errorMessage, error);
}
},
async deleteError() {
// clear cron errors on background job mode change
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
appId: 'core',
key: 'cronErrors',
} else {
desc +=
'<br>' +
t('settings', 'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.', {
linkstart:
'<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
linkend: '</a>',
});
}
// await confirmPassword();
return desc;
});
try {
await axios.delete(url);
} catch (error) {
console.error(error);
}
},
},
const isExecutionTooOld = computed((): boolean => {
if (!lastCronTimestamp.value) return false;
return dayjs().unix() - lastCronTimestamp.value > 600; // older than 10 minutes
});
const isLongExecutionCron = computed((): boolean => {
return dayjs().unix() - cronMaxAge > 24 * 3600 && backgroundJobsMode.value === 'cron';
});
// Methods
const getCurrentModeLabel = (): string => {
switch (backgroundJobsMode.value) {
case 'cron':
return t('settings', 'Cron (Recommended)');
case 'webcron':
return t('settings', 'Webcron');
case 'ajax':
return t('settings', 'AJAX');
default:
return t('settings', 'Unknown');
}
};
const refreshStatus = async (): Promise<void> => {
isRefreshing.value = true;
try {
// Use Inertia to refresh the current page data
router.reload({
only: ['lastCron', 'cronMaxAge', 'missingCrossReferencesCount'], // Also reload missing cross references count
onSuccess: () => {
showSuccess(t('settings', 'Background job status refreshed'));
},
onError: () => {
showError(t('settings', 'Failed to refresh status'));
},
onFinish: () => {
isRefreshing.value = false;
},
});
} catch (error) {
console.error('Failed to refresh status:', error);
showError(t('settings', 'Failed to refresh status'));
isRefreshing.value = false;
}
};
const startAutoRefresh = (): void => {
refreshTimer = setInterval(() => {
if (!isRefreshing.value) {
router.reload({ only: ['lastCron', 'cronMaxAge', 'missingCrossReferencesCount'] });
}
}, AUTO_REFRESH_INTERVAL);
};
const stopAutoRefresh = (): void => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
// Lifecycle hooks
onMounted(() => {
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
});
</script>
<style lang="css" scoped>
@ -185,7 +238,76 @@ export default {
width: initial;
}
.ajaxSwitch {
.background-jobs-mode {
margin-top: 1rem;
}
.background-jobs-mode h3 {
margin-bottom: 0.5rem;
font-weight: 600;
}
.current-mode {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.mode-label {
font-weight: 500;
color: var(--color-text-light);
}
.mode-value {
font-weight: 600;
color: var(--color-main-text);
padding: 0.25rem 0.5rem;
background-color: var(--color-background-hover);
border-radius: var(--border-radius);
}
.mode-description {
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
line-height: 1.4;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.actions button.primary {
background-color: var(--color-primary-element);
color: var(--color-primary-element-text);
}
.actions button.primary:hover:not(:disabled) {
background-color: var(--color-primary-element-hover);
}
.actions button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.current-mode {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>