diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 866a39566c..6b2ef97bc4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8348,6 +8348,63 @@ paths: items: type: string + /w/{workspace}/jobs/queue/resume_suspended: + post: + summary: resume all suspended jobs with the given suspend number + operationId: resumeSuspendedJobs + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: suspend number of the jobs to resume + required: true + content: + application/json: + schema: + type: object + properties: + suspend_number: + type: integer + description: the suspend number of jobs to resume + required: + - suspend_number + responses: + "200": + content: + application/json: + schema: + type: string + + /w/{workspace}/jobs/queue/cancel_suspended: + post: + summary: cancel all suspended jobs with the given suspend number + operationId: cancelSuspendedJobs + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: suspend number of the jobs to cancel + required: true + content: + application/json: + schema: + type: object + properties: + suspend_number: + type: integer + description: the suspend number of jobs to cancel + required: + - suspend_number + responses: + "200": + description: confirmation message + content: + application/json: + schema: + type: string + /w/{workspace}/jobs/completed/list: get: summary: list all completed jobs diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index b5c613b3db..1a0c013539 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -15,6 +15,7 @@ use futures::{StreamExt, TryFutureExt}; use http::{HeaderMap, HeaderName}; use itertools::Itertools; use quick_cache::sync::Cache; +use rand::SeedableRng; use serde_json::value::RawValue; use serde_json::Value; use sqlx::Pool; @@ -106,6 +107,56 @@ use windmill_queue::{ use crate::flow_conversations; use windmill_common::flow_conversations::MessageType; +pub async fn generate_unique_suspend_number( + db: &Pool, + workspace_id: &str, +) -> error::Result { + use rand::Rng; + + let mut rng = rand::rngs::StdRng::from_os_rng(); + let mut attempts = 0; + let max_attempts = 10; + + loop { + attempts += 1; + if attempts > max_attempts { + return Err(error::Error::InternalErr(format!( + "Failed to generate unique suspend number after {} attempts", + max_attempts + ))); + } + + let suspend_number = rng.random_range(101..i32::MAX); + + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE workspace_id = $1 AND suspend = $2)", + workspace_id, + suspend_number + ) + .fetch_one(db) + .await? + .unwrap_or(false); + + if !exists { + return Ok(suspend_number); + } + } +} + +pub async fn get_suspend_number_for_unactive_mode( + db: &Pool, + workspace_id: &str, + active_mode: Option, +) -> error::Result> { + if let Some(false) = active_mode { + Ok(Some( + generate_unique_suspend_number(db, workspace_id).await?, + )) + } else { + Ok(None) + } +} + pub fn workspaced_service() -> Router { let cors = CorsLayer::new() .allow_methods([http::Method::GET, http::Method::POST]) @@ -237,6 +288,8 @@ pub fn workspaced_service() -> Router { .route("/queue/position/:timestamp", get(get_queue_position)) .route("/queue/scheduled_for/:id", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) + .route("/queue/resume_suspended", post(resume_suspended_jobs)) + .route("/queue/cancel_suspended", post(cancel_suspended_jobs)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) .route( @@ -2176,6 +2229,129 @@ async fn cancel_selection( .await } +#[derive(Deserialize)] +pub struct ResumeSuspendedJobsRequest { + suspend_number: i32, +} + +async fn resume_suspended_jobs( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> error::JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = user_db.begin(&authed).await?; + + sqlx::query!( + r#" + WITH jobs_to_resume AS ( + SELECT + jq.id + FROM + v2_job_queue jq + INNER JOIN v2_job j ON j.id = jq.id + WHERE + j.workspace_id = $1 AND + jq.suspend = $2 AND + jq.canceled_by IS NULL + ) + UPDATE + v2_job_queue + SET + suspend = 0, + scheduled_for = NOW() + WHERE + id IN (SELECT id FROM jobs_to_resume) + "#, + w_id, + request.suspend_number + ) + .fetch_all(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "jobs.resume_suspended", + ActionKind::Update, + &w_id, + Some(&format!("suspend_number:{}", request.suspend_number)), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(format!( + "Resumed all suspended workspace jobs for suspend number: {}", + request.suspend_number + ))) +} + +#[derive(Deserialize)] +pub struct CancelSuspendedJobsRequest { + suspend_number: i32, +} + +async fn cancel_suspended_jobs( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> error::JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = user_db.begin(&authed).await?; + + sqlx::query!( + r#" + WITH jobs_to_cancel AS ( + SELECT + jq.id + FROM + v2_job_queue jq + INNER JOIN v2_job j ON j.id = jq.id + WHERE + j.workspace_id = $1 AND + jq.suspend = $2 AND + jq.canceled_by IS NULL + ) + UPDATE + v2_job_queue + SET + canceled_by = $3, + canceled_reason = 'Canceled all suspended jobs with suspend number' + WHERE + id IN (SELECT id FROM jobs_to_cancel) + "#, + w_id, + request.suspend_number, + authed.username + ) + .fetch_all(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "jobs.cancel_suspended", + ActionKind::Delete, + &w_id, + Some(&format!("suspend_number:{}", request.suspend_number)), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(format!( + "Canceled all suspended workspace jobs for suspend number: {}", + request.suspend_number + ))) +} + async fn list_filtered_job_uuids( authed: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api/src/triggers/email/handler_oss.rs b/backend/windmill-api/src/triggers/email/handler_oss.rs index 2f3a318bdd..363f515f40 100644 --- a/backend/windmill-api/src/triggers/email/handler_oss.rs +++ b/backend/windmill-api/src/triggers/email/handler_oss.rs @@ -46,6 +46,7 @@ impl TriggerCrud for EmailTrigger { _authed: &ApiAuthed, _w_id: &str, _trigger: TriggerData, + _suspend_number: Option, ) -> Result<()> { Err(Error::BadRequest( "Email triggers are not available in open source version".to_string(), @@ -60,6 +61,7 @@ impl TriggerCrud for EmailTrigger { _workspace_id: &str, _path: &str, _trigger: TriggerData, + _suspend_number: Option, ) -> Result<()> { Err(Error::BadRequest( "Email triggers are not available in open source version".to_string(), diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index 058f994f3a..6005e27c31 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -1,8 +1,7 @@ use crate::{ db::ApiAuthed, - triggers::{ - trigger_helpers::get_suspend_number_for_unactive_mode, StandardTriggerQuery, TriggerData, - }, + jobs::generate_unique_suspend_number, + triggers::{StandardTriggerQuery, TriggerData}, }; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -365,6 +364,20 @@ pub fn trigger_routes() -> Router { router } +pub async fn get_suspend_number_for_inactive_mode( + db: &DB, + workspace_id: &str, + active_mode: Option, +) -> Result> { + if let Some(false) = active_mode { + Ok(Some( + generate_unique_suspend_number(db, workspace_id).await?, + )) + } else { + Ok(None) + } +} + async fn create_trigger( Extension(handler): Extension>, authed: ApiAuthed, @@ -395,7 +408,9 @@ async fn create_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = new_trigger.base.path.clone(); - let suspend_number = get_suspend_number_for_unactive_mode(new_trigger.base.active_mode); + let suspend_number = + get_suspend_number_for_inactive_mode(&db, &workspace_id, new_trigger.base.active_mode) + .await?; handler .create_trigger( @@ -496,7 +511,9 @@ async fn update_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = edit_trigger.base.path.to_string(); - let suspend_number = get_suspend_number_for_unactive_mode(edit_trigger.base.active_mode); + let suspend_number = + get_suspend_number_for_inactive_mode(&db, &workspace_id, edit_trigger.base.active_mode) + .await?; handler .update_trigger( diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 8b8b34594f..8b67c02ae1 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -8,13 +8,14 @@ use crate::{ jobs::start_job_update_sse_stream, resources::try_get_resource_from_db_as, triggers::{ + handler::get_suspend_number_for_inactive_mode, http::{ refresh_routers, validate_authentication_method, HttpConfig, HttpConfigRequest, RouteExists, ROUTE_PATH_KEY_RE, VALID_ROUTE_PATH_RE, }, trigger_helpers::{ - get_runnable_format, get_suspend_number_for_unactive_mode, trigger_runnable, - trigger_runnable_and_wait_for_result, trigger_runnable_inner, RunnableId, + get_runnable_format, trigger_runnable, trigger_runnable_and_wait_for_result, + trigger_runnable_inner, RunnableId, }, Trigger, TriggerCrud, TriggerData, }, @@ -283,7 +284,8 @@ pub async fn create_many_http_triggers( for (new_http_trigger, route_path_key) in new_http_triggers.iter().zip(route_path_keys.iter()) { let suspend_number = - get_suspend_number_for_unactive_mode(new_http_trigger.base.active_mode); + get_suspend_number_for_inactive_mode(&db, &w_id, new_http_trigger.base.active_mode) + .await?; insert_new_trigger_into_db( &authed, &mut tx, @@ -1052,6 +1054,35 @@ async fn route_job( ) .map_err(|e| e.into_response())?; + if let Some(suspend_number) = trigger.suspend_number { + let _ = trigger_runnable( + &db, + Some(user_db), + authed, + &trigger.workspace_id, + &trigger.script_path, + trigger.is_flow, + args, + trigger.retry.as_ref(), + trigger.error_handler_path.as_deref(), + trigger.error_handler_args.as_ref(), + format!("http_trigger/{}", trigger.path), + None, + Some(suspend_number), + ) + .await + .map_err(|e| e.into_response())?; + + return Ok(( + StatusCode::OK, + format!( + "Trigger: {} in inactive mode, incoming request has been queued", + &trigger.path + ), + ) + .into_response()); + } + // Handle execution based on the execution mode match trigger.request_type { RequestType::SyncSse => { diff --git a/backend/windmill-api/src/triggers/mod.rs b/backend/windmill-api/src/triggers/mod.rs index af81442bc3..509837bb70 100644 --- a/backend/windmill-api/src/triggers/mod.rs +++ b/backend/windmill-api/src/triggers/mod.rs @@ -52,7 +52,7 @@ pub struct BaseTrigger { pub email: String, pub edited_at: DateTime, pub extra_perms: Option, - pub suspend_number: Option + pub suspend_number: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -117,6 +117,7 @@ pub struct BaseTriggerData { pub is_flow: bool, pub enabled: Option, pub active_mode: Option, + pub process_queued_jobs: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/backend/windmill-api/src/triggers/trigger_helpers.rs b/backend/windmill-api/src/triggers/trigger_helpers.rs index 64a4af44c8..bee69f516e 100644 --- a/backend/windmill-api/src/triggers/trigger_helpers.rs +++ b/backend/windmill-api/src/triggers/trigger_helpers.rs @@ -903,18 +903,6 @@ async fn trigger_script_with_retry_and_error_handler( Ok((uuid, delete_after_use)) } -pub fn generate_trigger_suspend_number() -> i32 { - use rand::Rng; - let mut rng = rand::rng(); - rng.random_range(101..i32::MAX) -} - -pub fn get_suspend_number_for_unactive_mode(active_mode: Option) -> Option { - if let Some(false) = active_mode { - return Some(generate_trigger_suspend_number()); - } - None -} async fn trigger_runnable_with_suspend( db: &DB, diff --git a/backend/windmill-api/src/triggers/websocket/listener.rs b/backend/windmill-api/src/triggers/websocket/listener.rs index 65f693d352..ed9c4b6d67 100644 --- a/backend/windmill-api/src/triggers/websocket/listener.rs +++ b/backend/windmill-api/src/triggers/websocket/listener.rs @@ -365,7 +365,25 @@ impl Listener for WebsocketTrigger { ), None => (None, None, None), }; - if let Some(ReturnMessageChannels { send_message_tx, mut killpill_rx }) = extra { + + if suspend_number.is_some() || extra.is_none() { + trigger_runnable( + db, + None, + authed, + &workspace_id, + &script_path, + *is_flow, + args, + retry, + error_handler_path, + error_handler_args, + format!("websocket_trigger/{}", listening_trigger.path), + None, + *suspend_number, + ) + .await?; + } else if let Some(ReturnMessageChannels { send_message_tx, mut killpill_rx }) = extra { let db_ = db.clone(); let url = url.to_owned(); let script_path = script_path.to_owned(); @@ -415,23 +433,6 @@ impl Listener for WebsocketTrigger { }; tokio::spawn(handle_response_f); - } else { - trigger_runnable( - db, - None, - authed, - &workspace_id, - &script_path, - *is_flow, - args, - retry, - error_handler_path, - error_handler_args, - format!("websocket_trigger/{}", listening_trigger.path), - None, - *suspend_number - ) - .await?; } Ok(()) diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index cecadb18b3..0fbefa59be 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -50,7 +50,7 @@ {#if isOpen}
diff --git a/frontend/src/lib/components/triggers/TriggerStateToggle.svelte b/frontend/src/lib/components/triggers/TriggerStateToggle.svelte index c4338e64ff..a7ca694b6e 100644 --- a/frontend/src/lib/components/triggers/TriggerStateToggle.svelte +++ b/frontend/src/lib/components/triggers/TriggerStateToggle.svelte @@ -7,23 +7,23 @@ import { workspaceStore } from '$lib/stores' import RunRow from '../runs/RunRow.svelte' import '../runs/runs-grid.css' + import { sendUserToast } from '$lib/toast' type Props = { active_mode: boolean suspendNumber?: number triggerPath?: string - onRunSuspendedJobs?: (data: { suspendNumber: number; jobIds: string[] }) => void } - let { active_mode = $bindable(), suspendNumber, onRunSuspendedJobs }: Props = $props() + let { active_mode = $bindable(), suspendNumber }: Props = $props() - let wasInUnactiveMode = $state(!active_mode) + let wasInInactiveMode = $state(!active_mode) let shouldShowModal = $state(false) $effect(() => { - if (active_mode && wasInUnactiveMode && suspendNumber !== undefined) { + if (active_mode && wasInInactiveMode && suspendNumber !== undefined) { shouldShowModal = true } else if (!active_mode) { - wasInUnactiveMode = true + wasInInactiveMode = true shouldShowModal = false } }) @@ -32,7 +32,6 @@ let error = $state(null) let processingAction = $state(false) let workspace = $workspaceStore! - let selectedJobIds = $state([]) let containerWidth = $state(1000) $effect(() => { if (shouldShowModal && suspendNumber !== undefined) { @@ -63,59 +62,60 @@ } async function runAllJobs() { - if (queuedJobs.length === 0) return + if (queuedJobs.length === 0 || !suspendNumber) return processingAction = true error = null try { - if (onRunSuspendedJobs && suspendNumber !== undefined) { - onRunSuspendedJobs({ suspendNumber, jobIds: queuedJobs.map((j) => j.id) }) - } - - closeModal() + const resumedJobs = await JobService.resumeSuspendedJobs({ + workspace, + requestBody: { + suspend_number: suspendNumber + } + }) + sendUserToast(resumedJobs) } catch (e) { error = `Failed to run jobs: ${e}` console.error('Failed to run jobs:', e) } finally { processingAction = false + + closeModal() } } async function discardAllJobs() { - if (queuedJobs.length === 0) return + if (queuedJobs.length === 0 || !suspendNumber) return processingAction = true error = null try { - const jobIds = queuedJobs.map((job) => job.id) - await JobService.cancelSelection({ + await JobService.cancelSuspendedJobs({ workspace, - requestBody: jobIds + requestBody: { + suspend_number: suspendNumber + } }) - closeModal() + sendUserToast(`Successfully canceled all jobs with suspend number: ${suspendNumber}`) } catch (e) { error = `Failed to discard jobs: ${e}` console.error('Failed to discard jobs:', e) } finally { processingAction = false + closeModal() } } function closeModal() { - wasInUnactiveMode = false + wasInInactiveMode = false shouldShowModal = false } - - function cancelToggle() { - active_mode = false - closeModal() - } - + {#if shouldShowModal} -
+
{#if loading}
{:else if queuedJobs.length === 0} -
No queued jobs found for this trigger.
+
+
+
No queued jobs found
+
This trigger has no jobs waiting to be processed.
+
+
{:else}
-

Queued Jobs ({queuedJobs.length})

+

Queued Jobs ({queuedJobs.length})

Click on any job to view details

-
-
@@ -159,7 +164,6 @@
-
{#each queuedJobs as job}
@@ -169,7 +173,6 @@ showTag={false} activeLabel={null} on:select={() => { - // Handle job selection - navigate to job details or show modal window.open(`/run/${job.id}?workspace=${workspace}`, '_blank') }} /> @@ -188,10 +191,6 @@ {/if}
- - {#if !loading && !error && queuedJobs.length > 0}
+ + {/if} = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) const isValid = $derived( !!kafkaResourcePath && @@ -188,6 +191,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number } async function loadTrigger(defaultConfig?: Record): Promise { @@ -215,7 +220,8 @@ extra_perms: extra_perms, error_handler_path, error_handler_args, - retry + retry, + active_mode } } @@ -382,6 +388,8 @@ {/if}
+ + {/if} = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) let optionTabSelected: 'connection_options' | 'error_handler' | 'retries' = $state('connection_options') @@ -202,6 +205,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number activateV5Options.topic_alias_maximum = Boolean(v5_config.topic_alias_maximum) activateV5Options.session_expiry_interval = Boolean(v5_config.session_expiry_interval) } catch (error) { @@ -240,7 +245,8 @@ is_flow, error_handler_path, error_handler_args, - retry + retry, + active_mode } } @@ -417,6 +423,8 @@ {/if} + + = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) const saveDisabled = $derived( pathError != '' || emptyString(script_path) || !can_write || !isValid @@ -190,6 +193,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number } async function loadTrigger(defaultConfig?: Record): Promise { @@ -218,7 +223,8 @@ use_jetstream: natsCfg.use_jetstream, error_handler_path, error_handler_args, - retry + retry, + active_mode } } @@ -398,6 +404,8 @@ {/if} + + = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) const errorMessage = $derived.by(() => { if (relations && relations.length > 0) { @@ -299,7 +302,8 @@ : undefined, error_handler_path, error_handler_args, - retry + retry, + active_mode } return cfg } @@ -320,6 +324,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number } async function loadTrigger(defaultConfig?: Record): Promise { @@ -575,6 +581,8 @@
{/if} + +
{#snippet badge()} {#if isEditor} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 26553ec080..ddcdace721 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -24,6 +24,7 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' + import TriggerStateToggle from '../TriggerStateToggle.svelte' interface Props { useDrawer?: boolean @@ -88,6 +89,8 @@ let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) const sqsConfig = $derived.by(getSaveCfg) const captureConfig = $derived.by(getCaptureConfig) @@ -176,6 +179,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number } catch (error) { sendUserToast(`Could not load SQS trigger config: ${error.body}`, true) } @@ -210,7 +215,8 @@ enabled, error_handler_path, error_handler_args, - retry + retry, + active_mode } } @@ -383,6 +389,8 @@ {/if}
+ + {/if} = $state({}) let retry: Retry | undefined = $state() + let active_mode = $state(true) + let suspend_number: number | undefined = $state(undefined) const websocketCfg = $derived.by(getSaveCfg) const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) @@ -219,6 +222,8 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + active_mode = cfg?.suspend_number ? false : true + suspend_number = cfg?.suspend_number } function getSaveCfg() { @@ -236,7 +241,8 @@ enabled, error_handler_path, error_handler_args, - retry + retry, + active_mode } } @@ -490,6 +496,8 @@ /> + +