feat: Allow setProgress and getProgress from within the script (#4400)

* Allow setting progress explicitly from script body.

This feature exposes:
 * `getProgress`
 * `setProgress`
 * `incProgress`

API in TypeScript client (python is coming soon).

NOTE: Progress cannot be out of range 0..100 and cannot decrease.

With exposed APIs there is also UI changes, so progress can be shown for individual jobs as well.

For optimization reasons, jobs start to ask for progress only after N-seconds of execution.

* feat: Add `shell.nix`

If you dont have anything but nix, dont worry, run nix-shell in root, or activate with direnv and get all needed dependencies

NOTE: You will still need docker

* feat: Add `dev.nu` to typescript client

Little helper function, allowing developer to work on ts client easier.

To use:
  `./dev.nu watch`

Now add import of windmill in body of your script and `//nobundle` on top of the file

Edit ts client in your favourite editor and hit save. Script will do the rest.

* Cleanup files

* Fix: Failed to deserialize query string: missing field `get_progress`

* perf: Implement non-naive polling mechanism for getting job progress

* Add independant delay for getProgress

Problem in `TestJobLoader`:

There should be 2 delays:

    One until we find our first progress (every 5s)
    Once we found our first progress, we can do it every second

* nit: Use `query_scalar!` instead of `query_as`

* Fix: Sql error, no rows returned by a query that expected to return at least one row

* refactor: Remove global CSS for JobProgressBar

* Change UI for progress of flow subjobs

* Replace `Step 1` with `Running` in ProgressBar for individual jobs

* Remove `incProgress`

incProgress is not very usefull and error-prone

* perf: Set metric only for jobs that are actually using it

(https://github.com/windmill-labs/windmill/pull/4373#discussion_r1759843773)

* Offload registering progress from clients to server

* Add `jobId?` argument to typescript-client's `setProgress` and `getProgress`

Allows to set progress of other jobs and flows,
if jobId specified, than flow id will be inferred automatically.

Could be used by SDK.

* Add `Error::MetricNotFound` for better error handling

* Fix: Make `JobProgressBar` display in red when failed

* Add persistant progress bar

Now you can reload the page after job is done and progress will be still there

* Allow succeeded individual job's progress bar stick to 100%

* Add python support 

* nit: Remove usage of undefined variable in python-client

* Add `async` in ts client (for error handling)

* nit(frontend): Remove unused import

* Dont load JobProgressBar when it is not needed

* nit: npm check fix

* cargo sqlx prepare

* fix sqlx

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
pyranota
2024-09-17 23:16:19 +00:00
committed by GitHub
parent f7454e621c
commit d6d4756b7a
31 changed files with 727 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
use nix
+1
View File
@@ -6,3 +6,4 @@ frontend/src/routes/test.svelte
CaddyfileRemoteMalo
*.swp
**/.idea/
.direnv
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE queue\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', flow_status->>'step', 'progress'], $1)\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Uuid"
]
},
"nullable": []
},
"hash": "76ca60e456022cf3d1931245b7daf22783c81bc757d735a4b247cc693dfed719"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT scalar_int FROM job_stats WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scalar_int",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text"
]
},
"nullable": [
true
]
},
"hash": "9422431d79de41518f651ef24e86819d7b6a2f5531740a7deea1b51775335977"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT (scalar_int)::int FROM job_stats WHERE job_id = $1 AND workspace_id = $2 AND metric_id = 'progress_perc'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scalar_int",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true
]
},
"hash": "d600a0ad953ed131952d9c46deb8f65143894f86517f2b5b2d51bc9a2857695c"
}
+52 -1
View File
@@ -5993,6 +5993,10 @@ paths:
in: query
schema:
type: integer
- name: get_progress
in: query
schema:
type: boolean
responses:
"200":
@@ -6012,6 +6016,8 @@ paths:
type: integer
mem_peak:
type: integer
progress:
type: integer
flow_status:
$ref: "#/components/schemas/WorkflowStatusRecord"
@@ -8647,6 +8653,52 @@ paths:
items:
$ref: "#/components/schemas/TimeseriesMetric"
/w/{workspace}/job_metrics/set_progress/{id}:
post:
summary: set job metrics
operationId: setJobProgress
tags:
- metrics
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
requestBody:
description: parameters for statistics retrieval
required: true
content:
application/json:
schema:
type: object
properties:
percent:
type: integer
flow_job_id:
type: string
format: uuid
responses:
"200":
description: Job progress updated
content:
application/json:
schema: {}
/w/{workspace}/job_metrics/get_progress/{id}:
get:
summary: get job progress
operationId: getJobProgress
tags:
- metrics
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
responses:
"200":
description: job progress between 0 and 99
content:
application/json:
schema:
type: integer
/service_logs/list_files:
get:
summary: list log files ordered by timestamp
@@ -8711,7 +8763,6 @@ paths:
schema:
type: string
/concurrency_groups/list:
get:
summary: List all concurrency groups
+106 -4
View File
@@ -1,12 +1,18 @@
use crate::db::DB;
use axum::{extract::Path, routing::post, Extension, Json, Router};
use axum::{
extract::Path,
routing::{get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use tower_http::cors::{Any, CorsLayer};
use uuid::Uuid;
use windmill_common::{
error,
job_metrics::{JobStatsRecord, MetricKind},
error::{self, Error},
job_metrics::{
record_metric, register_metric_for_job, JobStatsRecord, MetricKind, MetricNumericValue,
},
};
pub fn workspaced_service() -> Router {
@@ -15,7 +21,16 @@ pub fn workspaced_service() -> Router {
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_origin(Any);
Router::new().route("/get/:id", post(get_job_metrics).layer(cors.clone()))
Router::new()
.route("/get/:id", post(get_job_metrics).layer(cors.clone()))
.route(
"/set_progress/:id",
post(set_job_progress).layer(cors.clone()),
)
.route(
"/get_progress/:id",
get(get_job_progress).layer(cors.clone()),
)
}
#[derive(Deserialize)]
@@ -137,6 +152,93 @@ async fn get_job_metrics(
let response = JobStatsResponse { metrics_metadata, scalar_metrics, timeseries_metrics };
Ok(Json(response))
}
#[derive(Deserialize)]
struct JobProgressSetRequest {
percent: i32,
/// Optional parent flow id
/// Used to modify flow status
/// Specifically `progress` field in corresponding FlowStatusModule in `InProgress` state
flow_job_id: Option<Uuid>,
}
async fn set_job_progress(
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(JobProgressSetRequest { percent, flow_job_id }): Json<JobProgressSetRequest>,
) -> error::JsonResult<()> {
// If flow_job_id exists, than we should modify flow_status of corresponding module
// Individual jobs and flows are handled differently
if let Some(flow_job_id) = flow_job_id {
// TODO: Return error if trying to set completed job?
sqlx::query!(
"UPDATE queue
SET flow_status = JSONB_SET(flow_status, ARRAY['modules', flow_status->>'step', 'progress'], $1)
WHERE id = $2",
serde_json::json!(percent.clamp(0, 99)),
flow_job_id
)
.execute(&db)
.await?;
}
let record_progress = || {
record_metric(
&db,
w_id.clone(),
job_id,
"progress_perc".to_owned(),
MetricNumericValue::Integer(percent),
)
};
// Try to record
if let Err(err) = record_progress().await {
if matches!(err, Error::MetricNotFound(..)) {
// Register
// TODO: Reset progress after job is finished (in case it reruns same job)?
_ = register_metric_for_job(
&db,
w_id.clone(),
job_id,
"progress_perc".to_string(),
MetricKind::ScalarInt,
Some("Job Execution Progress (%)".to_owned()),
)
.await?;
// Retry recording progress
record_progress().await.map_err(|err| {
// If for some reason it still returns same error, this error will be converted to BadRequest and returned
if let Error::MetricNotFound(body) = err {
Error::BadRequest(body)
} else {
err
}
})?;
} else {
return Err(err);
}
};
return Ok(Json(()));
}
async fn get_job_progress(
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
) -> error::JsonResult<Option<i32>> {
let progress: Option<Option<i32>> = sqlx::query_scalar!(
"SELECT (scalar_int)::int FROM job_stats WHERE job_id = $1 AND workspace_id = $2 AND metric_id = 'progress_perc'",
job_id, w_id)
.fetch_optional(&db)
.await?;
let respond_value = if let Some(Some(progress)) = progress {
Some(progress.clamp(0, 99))
} else {
None
};
Ok(Json(respond_value))
}
fn timeseries_sample<T: Copy>(
from: Option<chrono::DateTime<chrono::Utc>>,
+19 -1
View File
@@ -4507,6 +4507,7 @@ pub async fn run_job_by_hash_inner(
pub struct JobUpdateQuery {
pub running: bool,
pub log_offset: i32,
pub get_progress: Option<bool>,
}
#[derive(Serialize)]
@@ -4516,6 +4517,7 @@ pub struct JobUpdate {
pub new_logs: Option<String>,
pub log_offset: Option<i32>,
pub mem_peak: Option<i32>,
pub progress: Option<i32>,
pub flow_status: Option<Box<serde_json::value::RawValue>>,
}
@@ -4583,7 +4585,7 @@ async fn get_job_update(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery { running, log_offset }): Query<JobUpdateQuery>,
Query(JobUpdateQuery { running, log_offset, get_progress }): Query<JobUpdateQuery>,
) -> error::JsonResult<JobUpdate> {
let record = sqlx::query_as::<_, JobUpdateRow>(
"SELECT running, substr(concat(coalesce(queue.logs, ''), job_logs.logs), greatest($1 - job_logs.log_offset, 0)) as logs, mem_peak,
@@ -4599,6 +4601,20 @@ async fn get_job_update(
.fetch_optional(&db)
.await?;
let progress: Option<i32> = if get_progress == Some(true){
sqlx::query_scalar!(
"SELECT scalar_int FROM job_stats WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
&w_id,
job_id,
"progress_perc"
)
.fetch_one(&db)
.await?
} else {
None
};
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
return Err(Error::BadRequest(
@@ -4616,6 +4632,7 @@ async fn get_job_update(
completed: None,
new_logs: record.logs,
mem_peak: record.mem_peak,
progress,
flow_status: record
.flow_status
.map(|x: sqlx::types::Json<Box<RawValue>>| x.0),
@@ -4648,6 +4665,7 @@ async fn get_job_update(
log_offset: record.log_offset,
new_logs: record.logs,
mem_peak: record.mem_peak,
progress,
flow_status: record
.flow_status
.map(|x: sqlx::types::Json<Box<RawValue>>| x.0),
+2
View File
@@ -30,6 +30,8 @@ pub enum Error {
NotFound(String),
#[error("Not authorized: {0}")]
NotAuthorized(String),
#[error("Metric not found: {0}")]
MetricNotFound(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Require Admin privileges for {0}")]
@@ -116,6 +116,7 @@ struct UntaggedFlowStatusModule {
type_: String,
id: Option<String>,
count: Option<u16>,
progress: Option<u8>,
job: Option<Uuid>,
iterator: Option<Iterator>,
flow_jobs: Option<Vec<Uuid>>,
@@ -147,6 +148,8 @@ pub enum FlowStatusModule {
id: String,
job: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
progress: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
iterator: Option<Iterator>,
#[serde(skip_serializing_if = "Option::is_none")]
flow_jobs: Option<Vec<Uuid>>,
@@ -237,6 +240,7 @@ impl<'de> Deserialize<'de> for FlowStatusModule {
branchall: untagged.branchall,
parallel: untagged.parallel.unwrap_or(false),
while_loop: untagged.while_loop.unwrap_or(false),
progress: untagged.progress,
}),
"Success" => Ok(FlowStatusModule::Success {
id: untagged
+1 -1
View File
@@ -112,7 +112,7 @@ pub async fn record_metric(
.await?;
if metric_kind_opt.is_none() {
return Err(error::Error::BadRequest(format!(
return Err(error::Error::MetricNotFound(format!(
"Metric {} not yet registered for job {}.",
metric_id, job_id
)));
+2
View File
@@ -4096,6 +4096,7 @@ async fn restarted_flows_resolution(
}),
parallel: parallel,
while_loop: false,
progress: None,
});
}
Ok(FlowModuleValue::ForloopFlow { parallel, .. }) => {
@@ -4133,6 +4134,7 @@ async fn restarted_flows_resolution(
branchall: None,
parallel: parallel,
while_loop: false,
progress: None,
});
}
_ => {
+5
View File
@@ -1520,14 +1520,19 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
tracing::debug!("set worker busy to 1");
}
match next_job {
Ok(Some(job)) => {
last_executed_job = None;
jobs_executed += 1;
tracing::debug!("started handling of job {}", job.id);
if matches!(job.job_kind, JobKind::Script | JobKind::Preview) {
if !dedicated_workers.is_empty() {
let key_o = if is_flow_worker {
job.flow_step_id.as_ref().map(|x| x.to_string())
@@ -2553,6 +2553,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
id: status_module.id(),
parallel: false,
while_loop,
progress: None,
}
}
NextStatus::AllFlowJobs { iterator, branchall, .. } => FlowStatusModule::InProgress {
@@ -2565,6 +2566,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
id: status_module.id(),
parallel: true,
while_loop: false,
progress: None,
},
NextStatus::NextBranchStep(NextBranch {
mut flow_jobs,
@@ -2587,6 +2589,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
id: status_module.id(),
parallel: false,
while_loop: false,
progress: None,
}
}
@@ -2600,6 +2603,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
id: status_module.id(),
parallel: false,
while_loop: false,
progress: None,
},
NextStatus::NextStep => {
FlowStatusModule::WaitingForExecutor { id: status_module.id(), job: one_uuid? }
@@ -13,6 +13,7 @@
import LogViewer from './LogViewer.svelte'
import TestJobLoader from './TestJobLoader.svelte'
import ModulePreviewForm from './ModulePreviewForm.svelte'
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
import { evalValue } from './flows/utils'
import type { PickableProperties } from './flows/previousResults'
import type DiffEditor from './DiffEditor.svelte'
@@ -31,10 +32,13 @@
getContext<FlowEditorContext>('FlowEditorContext')
// Test
let scriptProgress = undefined;
let testJobLoader: TestJobLoader
let testIsLoading = false
let testJob: Job | undefined = undefined
let jobProgressReset: () => void
let stepArgs: Record<string, any> | undefined = Object.fromEntries(
Object.keys(schema.properties ?? {}).map((k) => [
k,
@@ -49,6 +53,9 @@
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset();
const val = mod.value
// let jobId: string | undefined = undefined
if (val.type == 'rawscript') {
@@ -91,9 +98,12 @@
let forceJson = false
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
@@ -143,6 +153,9 @@
/>
</Pane>
<Pane size={50} minSize={10} class="text-sm text-tertiary">
{#if scriptProgress}
<JobProgressBar job={testJob} bind:scriptProgress bind:reset={jobProgressReset} compact={true} />
{/if}
{#if testJob != undefined && 'result' in testJob && testJob.result != undefined}
<div class="break-words relative h-full p-2">
<DisplayResult
@@ -167,7 +180,9 @@
{:else}
<div class="p-2">
{#if testIsLoading}
{#if !scriptProgress}
<Loader2 class="animate-spin" />
{/if}
{:else}
Test to see the result here
{/if}
@@ -12,6 +12,7 @@
import LogPanel from './scriptEditor/LogPanel.svelte'
import EditorBar, { EDITOR_BAR_WIDTH_THRESHOLD } from './EditorBar.svelte'
import TestJobLoader from './TestJobLoader.svelte'
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { Button } from './common'
import SplitPanesWrapper from './splitPanes/SplitPanesWrapper.svelte'
@@ -46,6 +47,8 @@
export let watchChanges = false
export let customUi: ScriptEditorWhitelabelCustomUi = {}
let jobProgressReset: () => void
let websocketAlive = {
pyright: false,
deno: false,
@@ -68,6 +71,7 @@
let args: Record<string, any> = initialArgs
let isValid: boolean = true
let scriptProgress = undefined;
// Test
let testIsLoading = false
@@ -98,6 +102,8 @@
}
function runTest() {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset();
//@ts-ignore
testJobLoader.runPreview(path, code, lang, args, tag)
}
@@ -214,6 +220,7 @@
<TestJobLoader
on:done={loadPastTests}
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
@@ -393,7 +400,12 @@
{editor}
{diffEditor}
{args}
/>
>
{#if scriptProgress}
<!-- Put to the slot in logpanel -->
<JobProgressBar job={testJob} bind:scriptProgress bind:reset={jobProgressReset} compact={true} />
{/if}
</LogPanel>
</Pane>
</Splitpanes>
</div>
@@ -13,6 +13,18 @@
export let jobUpdateLastFetch: Date | undefined = undefined
export let toastError = false
export let lazyLogs = false
// Will be set to number if job is not a flow
// If you want to find out progress of subjobs of a flow, check job.flow_status.progress
export let scriptProgress: number | undefined = undefined;
/// Last time asked for job progress
let lastTimeCheckedProgress: number | undefined = undefined;
/// Will try to poll progress every 5s and if once progress returned was not undefined, will be ignored
/// and getProgressRate will be used instead
const getProgressRetryRate: number = 5000;
/// How often loader poll progress
const getProgressRate: number = 1000;
const dispatch = createEventDispatcher()
@@ -97,7 +109,7 @@
workspace: workspace!,
id: job.id,
running: `running` in job && job.running,
logOffset: job.logs?.length ?? 0
logOffset: job.logs?.length ?? 0,
})
if ((job.logs ?? '').length == 0) {
@@ -115,6 +127,11 @@
tag: string | undefined,
lock?: string
): Promise<string> {
// Reset in case we rerun job without reloading
scriptProgress = undefined;
lastTimeCheckedProgress = undefined;
return abstractRun(() =>
JobService.runScriptPreview({
workspace: $workspaceStore!,
@@ -173,6 +190,27 @@
if (currentId === id) {
try {
if (job && `running` in job) {
let getProgress: boolean | undefined = undefined;
// We only pull individual job progress this way
// Flow's progress we are getting from FlowStatusModule of flow job
if (job.job_kind == "script" || job.job_kind == "preview"){
// First time, before running job, lastTimeCheckedProgress is always undefined
if (lastTimeCheckedProgress){
const lastTimeCheckedMs = Date.now() - lastTimeCheckedProgress;
// Ask for progress if the last time we asked is >5s OR the progress was once not undefined
if (lastTimeCheckedMs > getProgressRetryRate || (scriptProgress != undefined && lastTimeCheckedMs > getProgressRate)){
lastTimeCheckedProgress = Date.now();
getProgress = true;
}
} else {
// Make it think we asked for progress, but in reality we didnt. First 5s we want to wait without putting extra work on db
// 99.99% of the jobs won't have progress be set so we have to do a balance between having low-latency for jobs that use it and job that don't
// we would usually not care to have progress the first 5s and jobs that are less than 5s
lastTimeCheckedProgress = Date.now();
}
}
const offset = logOffset == 0 ? (job.logs?.length ? job.logs?.length + 1 : 0) : logOffset
console.log('getLogs')
@@ -180,9 +218,19 @@
workspace: workspace!,
id,
running: job.running,
logOffset: offset
logOffset: offset,
getProgress: getProgress
})
// Clamp number between two values with the following line:
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
if (previewJobUpdates.progress){
// Progress cannot go back and cannot be set to 100
scriptProgress = clamp(previewJobUpdates.progress, scriptProgress ?? 0, 99);
}
if (previewJobUpdates.new_logs) {
if (offset == 0) {
job.logs = previewJobUpdates.new_logs ?? ''
@@ -10,6 +10,7 @@
let subLength: number | undefined = undefined
let length = 1
let nextInProgress = false
let subIndexIsPercent: boolean = false
$: if (job) updateJobProgress(job)
@@ -38,7 +39,8 @@
newError = maxDone
maxDone = maxDone + 1
}
}
}
subIndexIsPercent = false;
// Loop is still iterating
if (module?.iterator) {
@@ -51,6 +53,12 @@
} else if (module?.branchall) {
subStepIndex = module.branchall.branch
subStepLength = module.branchall.len
} else if (module?.progress) {
const clamp = (num, min, max) => Math.min(Math.max(num, min), max)
subStepIndex = clamp(module?.progress, subIndex ?? 0, 99)
// Jitter protection >^^^^^^^^
subStepLength = 100
subIndexIsPercent = true;
}
error = newError
@@ -81,5 +89,6 @@
{subLength}
{subIndex}
{error}
bind:subIndexIsPercent
class={$$props.class}
/>
@@ -0,0 +1,58 @@
<script lang="ts">
import { type Job } from '$lib/gen'
import ProgressBar from '../progressBar/ProgressBar.svelte'
export let job: Job | undefined = undefined
export let compact: boolean = false;
/// Progress of currently running job
export let scriptProgress: number | undefined = undefined;
// Removes `Step 1` and replaces it with `Running`
export let hideStepTitle: boolean = false
let error: number | undefined = undefined
let index = 0
let subIndex: number = 0
let subLength: number = 100
let length = 1
let nextInProgress = false
$: if (job) updateJobProgress(job);
$: subIndex = scriptProgress ?? 0;
function updateJobProgress(job: Job) {
if (!job['running'] && !job['success']){
error = 0;
} else {
error = undefined;
}
// Anything that is success automatically gets 100% progress
if (job['success'] && scriptProgress)
index = 1, subLength = 0, subIndex = 0, scriptProgress = 100;
}
let resetP: any
export function reset() {
resetP?.()
error = undefined
subIndex = 0
subLength = 100
length = 1
index = 0
scriptProgress = undefined
}
</script>
<ProgressBar
bind:resetP
{length}
{index}
{nextInProgress}
{subLength}
{subIndex}
{error}
class={$$props.class}
bind:compact
bind:hideStepTitle
/>
@@ -1,6 +1,7 @@
<script lang="ts">
import { tweened } from 'svelte/motion'
import { linear } from 'svelte/easing'
import { twMerge } from 'tailwind-merge'
function getTween(initialValue = 0, duration = 200) {
return tweened(initialValue, {
@@ -14,6 +15,13 @@
export let subIndex: number | undefined
export let subLength: number | undefined
export let nextInProgress: boolean = false
// Used for displaying progress of subjob of flow
export let subIndexIsPercent: boolean = false
// Remove padding/margin, border radius and titles
// Used in individual job test runs
export let compact: boolean = false
// Removes `Step 1` and replaces it with `Running`
export let hideStepTitle: boolean = false
export let length: number
let duration = 200
@@ -43,6 +51,7 @@
</script>
<div class={$$props.class}>
{#if !compact}
<div
class="flex justify-between items-end font-medium mb-1 {error != undefined
? 'text-red-700 dark:text-red-200'
@@ -53,12 +62,17 @@
? 'Error occured'
: finished
? 'Done'
: hideStepTitle
? `Running`
: subIndexIsPercent
? `Step ${index + 1} (${subIndex !== undefined ? `${subIndex}%)` : ''}`
: `Step ${index + 1}${subIndex !== undefined ? `.${subIndex + 1}` : ''}`}
</span>
<span class="text-sm">
{$percent.toFixed(0)}%
</span>
</div>
{/if}
<!-- {#each state as step, index}
{index} {JSON.stringify(step)}
{/each} -->
@@ -68,7 +82,10 @@
{getPercent(index)}
|
{/each} -->
<div class="flex w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div class={twMerge(
"flex w-full bg-gray-200 overflow-hidden",
(compact) ? "rounded-none h-3" : "rounded-full h-4",
)}>
{#each new Array(length) as _, partIndex (partIndex)}
<div class="h-full relative border-white {partIndex === 0 ? '' : 'border-l'} w-full">
{#if partIndex == index && nextInProgress}
@@ -67,6 +67,7 @@
bind:watchJob
on:done={onDone}
/>
<div class="p-4 flex flex-col gap-2 items-start h-full">
{#if job}
<div class="flex gap-2 flex-wrap">
@@ -118,6 +118,7 @@
/>
</Pane>
<Pane>
<slot></slot>
{#if previewJob != undefined && 'result' in previewJob}
<div class="relative w-full h-full p-2">
<div class="relative">
@@ -8,7 +8,10 @@
type Script,
type WorkflowStatus,
type NewScript,
ConcurrencyGroupsService
ConcurrencyGroupsService,
MetricsService
} from '$lib/gen'
import {
canWrite,
@@ -68,6 +71,7 @@
import FlowMetadata from '$lib/components/FlowMetadata.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -91,6 +95,8 @@
let job: Job | undefined
let jobUpdateLastFetch: Date | undefined
let scriptProgress: number | undefined = undefined;
let viewTab: 'result' | 'logs' | 'code' | 'stats' = 'result'
let selectedJobStep: string | undefined = undefined
let branchOrIterationN: number = 0
@@ -190,6 +196,24 @@
let persistentScriptDefinition: Script | undefined = undefined
async function onJobLoaded() {
// We want to set up scriptProgress once job is loaded
// We need this to show progress bar if job has progress and is finished
if (job && job.type == "CompletedJob"){
// If error occured and job is completed
// than we fetch progress from server to display on what progress did it fail
// Could be displayed after run or as a historical page
// If opening page without running job (e.g. reloading page after run) progress will be displayed instantly
MetricsService.getJobProgress({
workspace: job.workspace_id ?? "NO_WORKSPACE",
id: job.id,
}).then(progress => {
// Returned progress is not always 100%, could be 65%, 33%, anything
// Its ok if its a failure and we want to keep that value
// But we want progress to be 100% if job has been succeeded
scriptProgress = progress;
});
}
if (job === undefined || job.job_kind !== 'script' || job.script_hash === undefined) {
return
}
@@ -200,6 +224,7 @@
if (script.restart_unless_cancelled ?? false) {
persistentScriptDefinition = script
}
}
$: {
@@ -319,6 +344,7 @@
<TestJobLoader
lazyLogs
bind:scriptProgress
on:done={() => job?.['result'] != undefined && (viewTab = 'result')}
bind:this={testJobLoader}
bind:getLogs
@@ -731,6 +757,9 @@
flowDone={job.type == 'CompletedJob'}
/>
{/if}
{#if scriptProgress}
<JobProgressBar {job} {scriptProgress} class="py-4" hideStepTitle={true}/>
{/if}
<!-- Logs and outputs-->
<div class="mr-2 sm:mr-0 mt-12">
<Tabs bind:selected={viewTab}>
+2
View File
@@ -481,6 +481,8 @@ components:
format: uuid
count:
type: integer
progress:
type: integer
iterator:
type: object
properties:
+54
View File
@@ -0,0 +1,54 @@
#! /usr/bin/env nu
let cache = "/tmp/windmill/cache/pip/"
# Clean cache
def "main clean" [] {
^rm -rf ($cache ++ "/wmill*")
}
# Watch changes in directory and autopatch (watchexec required)
def "main watch" [] {
# watchexec -w ../backend/windmill-api/openapi.yaml './dev.nu -g' &
# TODO: Watch openapi.yaml
^watchexec ./dev.nu -p
}
# Build client and move to windmill's cache
# To build you will need nushell and tsc (typescript compiler)
# If none arguments selected, all will be turned on
# If any argument specified, all others will be disabled
def main [
--gen(-g) # Generate code (OpenAPI codegen)
--compile(-c) # Compile code (TS >> JS)
--patch(-p) # Patch
] {
let do_all = not ($gen or $compile or $patch);
# TODO: Gen windmill-client.js
# TODO: Gen bundle? (README_DEV.md)
if ($do_all or $gen) {
print "Generating code from openapi.yml..."
./build.sh
}
if ($do_all or $patch) {
print "Patching cache..."
# Clean up in all versions
rm -rf ($cache ++ wmill*/wmill/*)
# Copy files from local ./dist to every wm-client version in cache
ls /tmp/windmill/cache/pip/wmill* | each {
|i|
let path = $i | get name;
^cp -r wmill/wmill/* ($path ++ "/wmill")
}
}
print Done!
}
+44
View File
@@ -346,6 +346,36 @@ class Windmill:
def set_state(self, value: Any):
self.set_resource(value, path=self.state_path, resource_type="state")
def set_progress(self, value: int, job_id: Optional[str] = None):
workspace = get_workspace()
flow_id = os.environ.get("WM_FLOW_JOB_ID")
job_id = job_id or os.environ.get("WM_JOB_ID")
if job_id != None:
job = self.get_job(job_id)
flow_id = job.get("parent_job")
self.post(
f"/w/{workspace}/job_metrics/set_progress/{job_id}",
json={
"percent": value,
"flow_job_id": flow_id or None,
},
)
def get_progress(self, job_id: Optional[str] = None ) -> Any:
workspace = get_workspace()
job_id = job_id or os.environ.get("WM_JOB_ID")
r = self.get(
f"/w/{workspace}/job_metrics/get_progress/{job_id}",
)
if r.status_code == 404:
print(f"Job {job_id} does not exist")
return None
else:
return r.json()
def set_flow_user_state(self, key: str, value: Any) -> None:
"""Set the user state of a flow at a given key"""
flow_id = self.get_root_job_id()
@@ -838,6 +868,20 @@ def set_state(value: Any) -> None:
"""
return _client.set_state(value)
@init_global_client
def set_progress(value: int, job_id: Optional[str] = None) -> None:
"""
Set the progress
"""
return _client.set_progress(value, job_id)
@init_global_client
def get_progress(job_id: Optional[str] = None) -> Any:
"""
Get the progress
"""
return _client.get_progress(job_id)
def set_shared_state_pickle(value: Any, path="state.pickle") -> None:
"""
+59
View File
@@ -0,0 +1,59 @@
{ pkgs ? import <nixpkgs> { } }:
/* based on
https://discourse.nixos.org/t/how-can-i-set-up-my-rust-programming-environment/4501/9
*/
let
rust_overlay = import (builtins.fetchTarball
"https://github.com/oxalica/rust-overlay/archive/master.tar.gz");
pkgs = import <nixpkgs> { overlays = [ rust_overlay ]; };
# TODO: Pin version?
rustVersion = "latest";
# rustVersion = "1.83.0";
rust = pkgs.rust-bin.nightly.${rustVersion}.default.override {
extensions = [
"rust-src" # for rust-analyzer
"rust-analyzer"
];
};
in pkgs.mkShell {
packages = with pkgs; [
rust
# rustup
cargo-watch
typescript # tsc
typescript-language-server
postgresql
watchexec # used in client's dev.nu
poetry # for python client
python312Packages.pip-tools # pip-compile
];
# buildInputs = with pkgs; [ xz lzma ];
# Add the following lines to set the LD_LIBRARY_PATH
LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath (with pkgs; [
lzma
libseccomp
bzip2
openssl_3_3
#
])}";
REMOTE = "http://127.0.0.1:8000";
REMOTE_LSP = "http://127.0.0.1:3001";
DATABASE_URL =
"postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable";
RUSTC_LINKER = "${pkgs.clang}/bin/clang";
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.clang}/bin/clang";
RUSTFLAGS =
"-C link-arg=-fuse-ld=${pkgs.mold}/bin/mold -Zshare-generics=y -Z threads=4";
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
# Use mold as a linker (for faster compilation)
}
+6
View File
@@ -9,3 +9,9 @@ undle --outfile=windmill.js --format=esm
node_modules/dts-bundle-generator/dist/bin/dts-bundle-generator.js -o
windmill.d.ts types/in dex.d.ts
# Develop client locally
`./dev.nu watch`
> If something not working, try to put //nobundle inside script body
+1 -1
View File
@@ -34,4 +34,4 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail } from "./client";' >> "${script_dirpath}/src/index.ts"
+44
View File
@@ -3,6 +3,7 @@ import {
VariableService,
JobService,
HelpersService,
MetricsService,
OidcService,
UserService,
} from "./index";
@@ -386,6 +387,49 @@ export async function setState(state: any): Promise<void> {
await setResource(state, undefined, "state");
}
/**
* Set the progress
* Progress cannot go back and limited to 0% to 99% range
* @param percent Progress to set in %
*/
export async function setProgress(percent: number, jobId?: any): Promise<void> {
const workspace = getWorkspace();
let flowId = getEnv("WM_FLOW_JOB_ID");
// If jobId specified we need to find if there is a parent/flow
if (jobId) {
const job = await JobService.getJob({
id: jobId ?? "NO_JOB_ID",
workspace,
noLogs: true
});
// Could be actual flowId or undefined
flowId = job.parent_job;
}
await MetricsService.setJobProgress({
id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
workspace,
requestBody: {
percent,
flow_job_id: (flowId == "") ? undefined : flowId,
}
});
}
/**
* Get the progress
* @returns Optional clamped between 0 and 100 progress value
*/
export async function getProgress(jobId?: any): Promise<number | null> {
// TODO: Delete or set to 100 completed job metrics
return await MetricsService.getJobProgress({
id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
workspace: getWorkspace(),
});
}
/**
* Set a flow user state
* @param key key of the state
+62
View File
@@ -0,0 +1,62 @@
#! /usr/bin/env nu
let cache = "/tmp/windmill/cache_nomount/bun/"
# Clean cache
def "main clean" [] {
^rm -rf ($cache ++ "/windmill-client")
}
# Watch changes in directory and autopatch (watchexec required)
def "main watch" [] {
# watchexec -w ../backend/windmill-api/openapi.yaml './dev.nu -g' &
# TODO: Watch openapi.yaml
^watchexec ./dev.nu
}
# Build client and move to windmill's cache
# To build you will need nushell and tsc (typescript compiler)
# If none arguments selected, all will be turned on
# If any argument specified, all others will be disabled
def main [
--gen(-g) # Generate code (OpenAPI codegen)
--compile(-c) # Compile code (TS >> JS)
--patch(-p) # Patch
] {
let do_all = not ($gen or $compile or $patch);
# TODO: Gen windmill-client.js
# TODO: Gen bundle? (README_DEV.md)
if ($do_all or $gen) {
print "Generating code from openapi.yml..."
./build.sh
}
if ($do_all or $compile) {
print "Compiling Typescript..."
tsc
}
if ($do_all or $patch) {
print "Patching cache..."
# Clean up in all versions
rm -rf ($cache ++ windmill-client@*/dist/*)
# Delete all script bundles
# rm -rf /tmp/windmill/cache/bun/*
# Copy files from local ./dist to every wm-client version in cache
ls ($cache ++ "windmill-client/") | each {
|i|
let path = $i | get name;
^cp -r dist/* ($path ++ "/dist")
}
}
print Done!
}