Merge pull request #24466 from overleaf/ls-script-runner

Script runner

GitOrigin-RevId: 4cc7004f05177dba2a2151aa6db7e75fb679d11d
This commit is contained in:
Liangjun Song
2025-04-11 09:06:46 +01:00
committed by Copybot
parent 8ad335cf47
commit c60ceaf932
14 changed files with 248 additions and 0 deletions

View File

@@ -81,6 +81,7 @@ const db = {
userAuditLogEntries: internalDb.collection('userAuditLogEntries'),
users: internalDb.collection('users'),
onboardingDataCollection: internalDb.collection('onboardingDataCollection'),
scriptLogs: internalDb.collection('scriptLogs'),
}
const connectionPromise = mongoClient.connect()

View File

@@ -0,0 +1,27 @@
import Mongoose from '../infrastructure/Mongoose.js'
export const ScriptLogSchema = new Mongoose.Schema(
{
canonicalName: { type: String, required: true },
filePathAtVersion: { type: String, required: true },
imageVersion: { type: String, required: true },
podName: { type: String, required: true },
startTime: { type: Date, default: Date.now },
endTime: { type: Date, default: null },
username: { type: String, required: true },
status: {
type: String,
enum: ['pending', 'success', 'error'],
default: 'pending',
required: true,
},
vars: { type: Object, required: true },
progressLogs: {
type: [{ timestamp: Date, message: String }],
required: true,
},
},
{ minimize: false, collection: 'scriptLogs' }
)
export const ScriptLog = Mongoose.model('ScriptLog', ScriptLogSchema)

View File

@@ -19,6 +19,7 @@ block append meta
- const staffAccess = sessionUser?.staffAccess
- const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement)
- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
- const canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
- const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on')
- const showSignUpLink = hasFeature('registration-page')
@@ -29,6 +30,7 @@ block append meta
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
enableUpgradeButton,
suppressNavbarRight: !!suppressNavbarRight,
suppressNavContentLinks: !!suppressNavContentLinks,

View File

@@ -27,6 +27,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
- var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof suppressNavbarRight === "undefined")
button.navbar-toggler.collapsed(
@@ -66,6 +67,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={
+dropdown-menu-link-item()(href="/admin/split-test") Manage Feature Flags
if canDisplaySurveyMenu
+dropdown-menu-link-item()(href="/admin/survey") Manage Surveys
if canDisplayScriptLogMenu
+dropdown-menu-link-item()(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras

View File

@@ -30,6 +30,7 @@ nav.navbar.navbar-default.navbar-main
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
- var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof(suppressNavbarRight) == "undefined")
.navbar-collapse.collapse#navbar-main-collapse
@@ -66,6 +67,9 @@ nav.navbar.navbar-default.navbar-main
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
if canDisplayScriptLogMenu
li
a(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras

View File

@@ -30,6 +30,7 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
- var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof(suppressNavbarRight) == "undefined")
.navbar-collapse.collapse#navbar-main-collapse
@@ -66,6 +67,9 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
if canDisplayScriptLogMenu
li
a(href="/admin/script-logs") View Script Logs
// loop over header_extras
each item in nav.header_extras

View File

@@ -8,6 +8,7 @@ export default function AdminMenu({
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
adminUrl,
}: Pick<
DefaultNavbarMetadata,
@@ -15,6 +16,7 @@ export default function AdminMenu({
| 'canDisplayAdminRedirect'
| 'canDisplaySplitTestMenu'
| 'canDisplaySurveyMenu'
| 'canDisplayScriptLogMenu'
| 'adminUrl'
>) {
const sendProjectListMB = useSendProjectListMB()
@@ -57,6 +59,11 @@ export default function AdminMenu({
Manage Surveys
</NavDropdownLinkItem>
) : null}
{canDisplayScriptLogMenu ? (
<NavDropdownLinkItem href="/admin/script-logs">
View Script Logs
</NavDropdownLinkItem>
) : null}
</NavDropdownMenu>
)
}

View File

@@ -22,6 +22,7 @@ function DefaultNavbar(props: DefaultNavbarMetadata) {
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
enableUpgradeButton,
suppressNavbarRight,
suppressNavContentLinks,
@@ -101,6 +102,7 @@ function DefaultNavbar(props: DefaultNavbarMetadata) {
canDisplayAdminRedirect={canDisplayAdminRedirect}
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
canDisplaySurveyMenu={canDisplaySurveyMenu}
canDisplayScriptLogMenu={canDisplayScriptLogMenu}
adminUrl={adminUrl}
/>
) : null}

View File

@@ -10,6 +10,7 @@ export type DefaultNavbarMetadata = {
canDisplayAdminRedirect: boolean
canDisplaySplitTestMenu: boolean
canDisplaySurveyMenu: boolean
canDisplayScriptLogMenu: boolean
enableUpgradeButton: boolean
suppressNavbarRight: boolean
suppressNavContentLinks: boolean

View File

@@ -51,6 +51,7 @@ import { Publisher } from '../../../types/subscription/dashboard/publisher'
import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
import { FooterMetadata } from '@/features/ui/components/types/footer-metadata'
import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log'
export interface Meta {
'ol-ExposedSettings': ExposedSettings
'ol-addonPrices': Record<string, { annual: string; monthly: string }>
@@ -162,6 +163,7 @@ export interface Meta {
'ol-oauthProviders': OAuthProviders
'ol-odcRole': string
'ol-overallThemes': OverallThemeMeta[]
'ol-pages': number
'ol-passwordStrengthOptions': PasswordStrengthOptions
'ol-paywallPlans': { [key: string]: string }
'ol-personalAccessTokens': AccessToken[] | undefined
@@ -188,6 +190,8 @@ export interface Meta {
'ol-recurlySubdomain': string
'ol-ro-mirror-on-client-no-local-storage': boolean
'ol-samlError': SAMLError | undefined
'ol-script-log': ScriptLogType
'ol-script-logs': ScriptLogType[]
'ol-settingsGroupSSO': { enabled: boolean } | undefined
'ol-settingsPlans': Plan[]
'ol-shouldAllowEditingDetails': boolean

View File

@@ -0,0 +1,32 @@
/* eslint-disable no-unused-vars */
import Helpers from './lib/helpers.mjs'
const tags = ['saas']
const indexes = [
{
key: { canonicalName: 1 },
name: 'canonicalName_1',
},
{
key: { username: 1 },
name: 'username_1',
},
]
const migrate = async client => {
const { db } = client
await Helpers.addIndexesToCollection(db.scriptLogs, indexes)
}
const rollback = async client => {
const { db } = client
await Helpers.dropIndexesFromCollection(db.scriptLogs, indexes)
}
export default {
tags,
migrate,
rollback,
}

View File

@@ -0,0 +1,31 @@
// Import the script runner utility (adjust the path as needed)
import { scriptRunner } from '../lib/ScriptRunner.mjs'
const subJobs = 30
/**
* Your script's main work goes here.
* It must be an async function and accept `trackProgress`.
* @param {(message: string) => Promise<void>} trackProgress - Call this to log progress.
*/
async function main(trackProgress) {
for (let i = 0; i < subJobs; i++) {
await new Promise(resolve => setTimeout(() => resolve(), 1000))
await trackProgress(`Job in progress ${i + 1}/${subJobs}`)
}
await trackProgress('Job finished')
}
// Define any variables your script needs (optional)
const scriptVariables = {
subJobs,
}
// --- Execute the script using the runner with async/await ---
try {
await scriptRunner(main, scriptVariables)
process.exit()
} catch (error) {
console.error(error)
process.exit(1)
}

View File

@@ -0,0 +1,55 @@
# Script Runner
## Overview
The Script Runner wraps your script's main logic to automatically handle logging, status tracking (success/error), and progress updates. Script execution status can be viewed from "Script Logs" portal page.
## Features
- Automatically logs the start and end of your script.
- Records the final status ('success' or 'error').
- Provides a simple function (`trackProgress`) to your script for logging custom progress steps.
- Captures script parameters and basic environment details.
## Usage
1. **Import `scriptRunner`**.
2. **Define your script's main logic** as an `async` function that accepts `trackProgress` as its argument (can ignore `trackProgress` if you don't need to track progress).
3. **Call `scriptRunner`**, passing your function and any variables it needs.
4. **Check script execution status** by visiting the "Script Logs" portal page using the URL printed in the console output.
**Example:**
```javascript
// Import the script runner utility (adjust the path as needed)
import { scriptRunner } from './lib/ScriptRunner.mjs'
const subJobs = 30
/**
* Your script's main work goes here.
* It must be an async function and accept `trackProgress`.
* @param {(message: string) => void} trackProgress - Call this to log progress.
*/
async function main(trackProgress) {
for (let i = 0; i < subJobs; i++) {
await new Promise(resolve => setTimeout(() => resolve(), 1000))
await trackProgress(`Job in progress ${i + 1}/${subJobs}`)
}
await trackProgress('Job finished')
}
// Define any variables your script needs (optional)
const scriptVariables = {
subJobs,
}
// --- Execute the script using the runner with async/await ---
try {
await scriptRunner(main, scriptVariables)
process.exit()
} catch (error) {
console.error(error)
process.exit(1)
}
```

View File

@@ -0,0 +1,75 @@
import { ScriptLog } from '../../app/src/models/ScriptLog.mjs'
import Settings from '@overleaf/settings'
async function beforeScriptExecution(canonicalName, vars, scriptPath) {
let log = new ScriptLog({
canonicalName,
filePathAtVersion: scriptPath,
podName: process.env.OL_POD_NAME,
username: process.env.OL_USERNAME,
imageVersion: process.env.OL_IMAGE_VERSION,
vars,
})
log = await log.save()
console.log(
'\n==================================' +
'\n✨ Your script is running!' +
'\n📊 Track progress at:' +
`\n${Settings.adminUrl}/admin/script-log/${log._id}` +
'\n==================================\n'
)
return log._id
}
async function afterScriptExecution(logId, status) {
await ScriptLog.findByIdAndUpdate(logId, { status, endTime: new Date() })
}
/**
* @param {(trackProgress: (progress: string) => Promise<void>) => Promise<any>} main - Main function for the script
* @param {Object} vars - Variables to be used in the script
* @param {string} canonicalName - The canonical name of the script, default to filename
* @param {string} scriptPath - The file path of the script, default to process.argv[1]
* @returns {Promise<void>}
* @async
*/
export async function scriptRunner(
main,
vars = {},
canonicalName = process.argv[1].split('/').pop().split('.')[0],
scriptPath = process.argv[1]
) {
const isSaaS = Boolean(Settings.overleaf)
if (!isSaaS) {
await main(async message => {
console.warn(message)
})
return
}
const logId = await beforeScriptExecution(canonicalName, vars, scriptPath)
async function trackProgress(message) {
try {
console.warn(message)
await ScriptLog.findByIdAndUpdate(logId, {
$push: {
progressLogs: {
timestamp: new Date(),
message,
},
},
})
} catch (error) {
console.error('Error tracking progress:', error)
}
}
try {
await main(trackProgress)
} catch (error) {
await afterScriptExecution(logId, 'error')
throw error
}
await afterScriptExecution(logId, 'success')
}