mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
all
This commit is contained in:
Generated
+1
@@ -15780,6 +15780,7 @@ dependencies = [
|
||||
"windmill-operator",
|
||||
"windmill-queue",
|
||||
"windmill-runtime-nativets",
|
||||
"windmill-sandbox",
|
||||
"windmill-test-utils",
|
||||
"windmill-worker",
|
||||
"windows-service",
|
||||
|
||||
@@ -261,6 +261,7 @@ async-nats.workspace = true
|
||||
aws-sdk-sqs.workspace = true
|
||||
aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
windmill-sandbox = { workspace = true, features = ["parquet"] }
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/*!
|
||||
* Integration test for sandbox snapshot building.
|
||||
*
|
||||
* Requires:
|
||||
* - `crane` CLI in PATH (for Docker image export)
|
||||
* - A running PostgreSQL database (via DATABASE_URL or sqlx test infrastructure)
|
||||
*
|
||||
* Skips gracefully if `crane` is not available.
|
||||
*/
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
mod tests {
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
/// Check if `crane` CLI is available in PATH.
|
||||
fn crane_available() -> bool {
|
||||
std::process::Command::new("crane")
|
||||
.arg("version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a minimal snapshot from busybox (no setup script).
|
||||
/// Verifies the full pipeline: crane export -> tar.gz -> upload to filesystem store -> DB status update.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_build_snapshot_minimal(db: Pool<Postgres>) {
|
||||
if !crane_available() {
|
||||
eprintln!("SKIPPED: crane not found in PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let w_id = "test-workspace";
|
||||
let store_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Configure filesystem-backed object store for the test workspace
|
||||
let lfs = serde_json::json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": store_dir.path().to_string_lossy(),
|
||||
});
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs,
|
||||
w_id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Insert a pending snapshot row (mirrors what the API create_snapshot does)
|
||||
sqlx::query!(
|
||||
"INSERT INTO sandbox_snapshot \
|
||||
(workspace_id, name, tag, s3_key, docker_image, status, created_by) \
|
||||
VALUES ($1, $2, $3, '', $4, 'pending', 'test-user')",
|
||||
w_id,
|
||||
"integ-test",
|
||||
"latest",
|
||||
"busybox:latest",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Run the build
|
||||
windmill_sandbox::build_snapshot(w_id, "integ-test", "latest", "busybox:latest", None, &db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify snapshot is ready with valid metadata
|
||||
let row = sqlx::query!(
|
||||
"SELECT status, s3_key, size_bytes, content_hash \
|
||||
FROM sandbox_snapshot \
|
||||
WHERE workspace_id = $1 AND name = $2 AND tag = $3",
|
||||
w_id,
|
||||
"integ-test",
|
||||
"latest",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.status, "ready", "snapshot status should be 'ready'");
|
||||
assert!(
|
||||
row.size_bytes.unwrap_or(0) > 0,
|
||||
"snapshot should have non-zero size"
|
||||
);
|
||||
assert!(
|
||||
!row.content_hash.is_empty(),
|
||||
"content_hash should be populated"
|
||||
);
|
||||
assert!(
|
||||
row.s3_key.starts_with("sandbox/snapshots/"),
|
||||
"s3_key should have correct prefix, got: {}",
|
||||
row.s3_key
|
||||
);
|
||||
|
||||
// Verify the tar.gz file was written to the filesystem store
|
||||
let file_path = store_dir.path().join(&row.s3_key);
|
||||
assert!(
|
||||
file_path.exists(),
|
||||
"snapshot tar.gz should exist at {}",
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
// Verify the tar.gz is valid
|
||||
let bytes = std::fs::read(&file_path).unwrap();
|
||||
let dest = tempfile::tempdir().unwrap();
|
||||
windmill_sandbox::untar_gz(&bytes, dest.path()).unwrap();
|
||||
|
||||
// busybox should have /bin/busybox
|
||||
assert!(
|
||||
dest.path().join("bin/busybox").exists(),
|
||||
"unpacked snapshot should contain /bin/busybox"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a snapshot with a setup script that creates a file.
|
||||
/// Verifies the setup script ran inside the rootfs.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_build_snapshot_with_setup_script(db: Pool<Postgres>) {
|
||||
if !crane_available() {
|
||||
eprintln!("SKIPPED: crane not found in PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
// nsjail is needed for setup scripts
|
||||
let nsjail_available = std::process::Command::new(
|
||||
std::env::var("NSJAIL_PATH").unwrap_or_else(|_| "nsjail".to_string()),
|
||||
)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.is_ok();
|
||||
|
||||
if !nsjail_available {
|
||||
eprintln!("SKIPPED: nsjail not found in PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let w_id = "test-workspace";
|
||||
let store_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let lfs = serde_json::json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": store_dir.path().to_string_lossy(),
|
||||
});
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs,
|
||||
w_id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO sandbox_snapshot \
|
||||
(workspace_id, name, tag, s3_key, docker_image, setup_script, status, created_by) \
|
||||
VALUES ($1, $2, $3, '', $4, $5, 'pending', 'test-user')",
|
||||
w_id,
|
||||
"setup-test",
|
||||
"latest",
|
||||
"busybox:latest",
|
||||
"echo 'hello from setup' > /tmp/setup_marker",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
windmill_sandbox::build_snapshot(
|
||||
w_id,
|
||||
"setup-test",
|
||||
"latest",
|
||||
"busybox:latest",
|
||||
Some("echo 'hello from setup' > /tmp/setup_marker"),
|
||||
&db,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify status
|
||||
let row = sqlx::query!(
|
||||
"SELECT status, s3_key FROM sandbox_snapshot \
|
||||
WHERE workspace_id = $1 AND name = $2 AND tag = $3",
|
||||
w_id,
|
||||
"setup-test",
|
||||
"latest",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.status, "ready");
|
||||
|
||||
// Unpack and verify the setup script's marker file exists
|
||||
let bytes = std::fs::read(store_dir.path().join(&row.s3_key)).unwrap();
|
||||
let dest = tempfile::tempdir().unwrap();
|
||||
windmill_sandbox::untar_gz(&bytes, dest.path()).unwrap();
|
||||
|
||||
let marker = dest.path().join("tmp/setup_marker");
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"setup script marker file should exist in the snapshot"
|
||||
);
|
||||
let content = std::fs::read_to_string(&marker).unwrap();
|
||||
assert_eq!(content.trim(), "hello from setup");
|
||||
}
|
||||
|
||||
/// Verify that build_snapshot correctly sets status to 'failed' and records
|
||||
/// the error when given an invalid docker image.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_build_snapshot_invalid_image(db: Pool<Postgres>) {
|
||||
if !crane_available() {
|
||||
eprintln!("SKIPPED: crane not found in PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let w_id = "test-workspace";
|
||||
let store_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let lfs = serde_json::json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": store_dir.path().to_string_lossy(),
|
||||
});
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs,
|
||||
w_id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO sandbox_snapshot \
|
||||
(workspace_id, name, tag, s3_key, docker_image, status, created_by) \
|
||||
VALUES ($1, $2, $3, '', $4, 'pending', 'test-user')",
|
||||
w_id,
|
||||
"bad-image",
|
||||
"latest",
|
||||
"nonexistent-registry.invalid/no-such-image:v999",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = windmill_sandbox::build_snapshot(
|
||||
w_id,
|
||||
"bad-image",
|
||||
"latest",
|
||||
"nonexistent-registry.invalid/no-such-image:v999",
|
||||
None,
|
||||
&db,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "build should fail for invalid image");
|
||||
|
||||
// Verify the DB status was set to 'failed' with an error message
|
||||
let row = sqlx::query!(
|
||||
"SELECT status, build_error FROM sandbox_snapshot \
|
||||
WHERE workspace_id = $1 AND name = $2 AND tag = $3",
|
||||
w_id,
|
||||
"bad-image",
|
||||
"latest",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.status, "failed");
|
||||
assert!(
|
||||
row.build_error.is_some(),
|
||||
"build_error should be populated on failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -282,20 +282,51 @@ pub async fn build_snapshot(
|
||||
if let Some(script) = setup_script {
|
||||
if !script.trim().is_empty() {
|
||||
tracing::info!("Running setup script for snapshot {name}:{tag}");
|
||||
let output = Command::new("chroot")
|
||||
|
||||
// Copy host resolv.conf so package managers can resolve DNS
|
||||
let etc_dir = rootfs_dir.join("etc");
|
||||
tokio::fs::create_dir_all(&etc_dir).await.ok();
|
||||
tokio::fs::copy("/etc/resolv.conf", etc_dir.join("resolv.conf"))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let nsjail_path =
|
||||
std::env::var("NSJAIL_PATH").unwrap_or_else(|_| "nsjail".to_string());
|
||||
let rootfs_str = rootfs_dir.to_string_lossy().to_string();
|
||||
let output = Command::new(&nsjail_path)
|
||||
.args([
|
||||
&rootfs_dir.to_string_lossy().to_string(),
|
||||
"--mode",
|
||||
"once",
|
||||
"--chroot",
|
||||
&rootfs_str,
|
||||
"--rw",
|
||||
"--keep_env",
|
||||
"--disable_clone_newnet",
|
||||
"--quiet",
|
||||
"--rlimit_fsize",
|
||||
"max",
|
||||
"--rlimit_as",
|
||||
"max",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to run setup: {e}")))?;
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to run nsjail: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::ExecutionErr(format!("Setup script failed: {stderr}")));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let combined = if stdout.is_empty() {
|
||||
stderr.to_string()
|
||||
} else {
|
||||
format!("{stderr}\n{stdout}")
|
||||
};
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Setup script failed: {combined}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +344,9 @@ pub async fn build_snapshot(
|
||||
.map_err(|e| Error::ExecutionErr(format!("Spawn blocking failed: {e}")))??;
|
||||
|
||||
let size = bytes.len();
|
||||
if size > CE_SNAPSHOT_SIZE_LIMIT {
|
||||
if !*windmill_common::ee_oss::LICENSE_KEY_VALID.read().await
|
||||
&& size > CE_SNAPSHOT_SIZE_LIMIT
|
||||
{
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Snapshot size ({:.1} MB) exceeds the {} MB limit. \
|
||||
Upgrade to Windmill EE for unlimited snapshot sizes.",
|
||||
@@ -394,7 +427,9 @@ pub async fn upload_snapshot_bytes(
|
||||
use windmill_common::error::Error;
|
||||
|
||||
let size = body.len();
|
||||
if size > CE_SNAPSHOT_SIZE_LIMIT {
|
||||
if !*windmill_common::ee_oss::LICENSE_KEY_VALID.read().await
|
||||
&& size > CE_SNAPSHOT_SIZE_LIMIT
|
||||
{
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Snapshot size ({:.1} MB) exceeds the {} MB limit. \
|
||||
Upgrade to Windmill EE for unlimited snapshot sizes.",
|
||||
|
||||
@@ -13,15 +13,19 @@
|
||||
TabContent,
|
||||
Tabs
|
||||
} from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import { Plus, Trash, Upload, RefreshCw, Info } from 'lucide-svelte'
|
||||
import { Plus, Trash, Upload, RefreshCw, Info, ExternalLink } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
|
||||
type Snapshot = {
|
||||
workspace_id: string
|
||||
@@ -56,25 +60,123 @@
|
||||
let snapshots: Snapshot[] | undefined = $state(undefined)
|
||||
let volumes: Volume[] | undefined = $state(undefined)
|
||||
|
||||
// Create snapshot form
|
||||
let newSnapshotName = $state('')
|
||||
let newSnapshotTag = $state('latest')
|
||||
let newSnapshotDockerImage = $state('')
|
||||
let newSnapshotSetupScript = $state('')
|
||||
// Create snapshot drawer
|
||||
let createDrawer: Drawer | undefined = $state()
|
||||
let snapshotMode: 'builder' | 'dockerfile' | 'upload' = $state('builder')
|
||||
|
||||
// Shared state
|
||||
let newName = $state('')
|
||||
let newTag = $state('latest')
|
||||
|
||||
// Builder state
|
||||
let newDockerImage = $state('')
|
||||
let newSetupScript = $state('')
|
||||
|
||||
// Dockerfile state
|
||||
let dockerfileFileInput: HTMLInputElement | undefined = $state()
|
||||
let dockerfileContent = $state('')
|
||||
let dockerfileWarnings: string[] = $derived.by(() => {
|
||||
if (!dockerfileContent.trim()) return []
|
||||
return parseDockerfile(dockerfileContent).warnings
|
||||
})
|
||||
|
||||
// Upload state
|
||||
let uploadFile: File | null = $state(null)
|
||||
let uploading = $state(false)
|
||||
|
||||
// Create volume form
|
||||
let newVolumeName = $state('')
|
||||
|
||||
// Upload state
|
||||
let uploadDrawer: Drawer | undefined = $state()
|
||||
let uploadName = $state('')
|
||||
let uploadTag = $state('latest')
|
||||
let uploadFile: File | null = $state(null)
|
||||
let uploading = $state(false)
|
||||
// Detail drawer
|
||||
let detailDrawer: Drawer | undefined = $state()
|
||||
let selectedSnapshot: Snapshot | undefined = $state()
|
||||
|
||||
function openSnapshotDetail(snapshot: Snapshot) {
|
||||
selectedSnapshot = snapshot
|
||||
detailDrawer?.openDrawer()
|
||||
}
|
||||
|
||||
// Instructions drawer
|
||||
let instructionsDrawer: Drawer | undefined = $state()
|
||||
|
||||
function parseDockerfile(content: string): {
|
||||
dockerImage: string
|
||||
setupScript: string
|
||||
warnings: string[]
|
||||
} {
|
||||
const lines = content.split('\n')
|
||||
let dockerImage = ''
|
||||
let scriptParts: string[] = []
|
||||
let warnings: string[] = []
|
||||
let continuation = ''
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trimEnd()
|
||||
|
||||
// Handle line continuations
|
||||
if (continuation) {
|
||||
line = continuation + ' ' + line.trimStart()
|
||||
continuation = ''
|
||||
}
|
||||
if (line.endsWith('\\')) {
|
||||
continuation = line.slice(0, -1).trimEnd()
|
||||
continue
|
||||
}
|
||||
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
const spaceIdx = trimmed.indexOf(' ')
|
||||
if (spaceIdx === -1) continue
|
||||
const instruction = trimmed.slice(0, spaceIdx).toUpperCase()
|
||||
const args = trimmed.slice(spaceIdx + 1).trim()
|
||||
|
||||
switch (instruction) {
|
||||
case 'FROM':
|
||||
if (!dockerImage) {
|
||||
dockerImage = args.split(/\s+/)[0]
|
||||
}
|
||||
break
|
||||
case 'RUN':
|
||||
scriptParts.push(args)
|
||||
break
|
||||
case 'ENV': {
|
||||
const eqIdx = args.indexOf('=')
|
||||
if (eqIdx !== -1) {
|
||||
scriptParts.push(`export ${args}`)
|
||||
} else {
|
||||
const parts = args.split(/\s+/, 2)
|
||||
if (parts.length === 2) {
|
||||
scriptParts.push(`export ${parts[0]}=${parts[1]}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'WORKDIR':
|
||||
scriptParts.push(`mkdir -p ${args} && cd ${args}`)
|
||||
break
|
||||
default:
|
||||
warnings.push(`Unsupported instruction: ${instruction} (line ${i + 1})`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dockerImage,
|
||||
setupScript: scriptParts.join('\n'),
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
newName = ''
|
||||
newTag = 'latest'
|
||||
newDockerImage = ''
|
||||
newSetupScript = ''
|
||||
dockerfileContent = ''
|
||||
uploadFile = null
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit) {
|
||||
const resp = await fetch(`/api/w/${$workspaceStore}/sandbox${path}`, options)
|
||||
if (!resp.ok) {
|
||||
@@ -88,6 +190,11 @@
|
||||
try {
|
||||
const resp = await apiFetch('/snapshots')
|
||||
snapshots = await resp.json()
|
||||
if (selectedSnapshot) {
|
||||
selectedSnapshot = snapshots?.find(
|
||||
(s) => s.name === selectedSnapshot!.name && s.tag === selectedSnapshot!.tag
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to load snapshots: ${e.message}`, true)
|
||||
snapshots = []
|
||||
@@ -104,27 +211,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot(close: () => void) {
|
||||
async function createSnapshot() {
|
||||
const tag = newTag || 'latest'
|
||||
try {
|
||||
await apiFetch('/snapshots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newSnapshotName,
|
||||
tag: newSnapshotTag || 'latest',
|
||||
docker_image: newSnapshotDockerImage,
|
||||
setup_script: newSnapshotSetupScript || null
|
||||
if (snapshotMode === 'upload') {
|
||||
if (!uploadFile || !newName) return
|
||||
uploading = true
|
||||
const resp = await fetch(
|
||||
`/api/w/${$workspaceStore}/sandbox/snapshots/${encodeURIComponent(newName)}/${encodeURIComponent(tag)}/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: uploadFile
|
||||
}
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error((await resp.text()) || resp.statusText)
|
||||
}
|
||||
sendUserToast(`Snapshot ${newName}:${tag} uploaded successfully`)
|
||||
} else {
|
||||
let dockerImage = newDockerImage
|
||||
let setupScript = newSetupScript
|
||||
if (snapshotMode === 'dockerfile') {
|
||||
const parsed = parseDockerfile(dockerfileContent)
|
||||
dockerImage = parsed.dockerImage
|
||||
setupScript = parsed.setupScript
|
||||
}
|
||||
await apiFetch('/snapshots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName,
|
||||
tag,
|
||||
docker_image: dockerImage,
|
||||
setup_script: setupScript || null
|
||||
})
|
||||
})
|
||||
})
|
||||
sendUserToast(`Snapshot ${newSnapshotName}:${newSnapshotTag || 'latest'} created`)
|
||||
newSnapshotName = ''
|
||||
newSnapshotTag = 'latest'
|
||||
newSnapshotDockerImage = ''
|
||||
newSnapshotSetupScript = ''
|
||||
close()
|
||||
sendUserToast(`Snapshot ${newName}:${tag} created`)
|
||||
}
|
||||
resetCreateForm()
|
||||
createDrawer?.closeDrawer()
|
||||
loadSnapshots()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to create snapshot: ${e.message}`, true)
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +265,7 @@
|
||||
method: 'DELETE'
|
||||
})
|
||||
sendUserToast(`Deleted snapshot ${name}:${tag}`)
|
||||
detailDrawer?.closeDrawer()
|
||||
loadSnapshots()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to delete snapshot: ${e.message}`, true)
|
||||
@@ -147,40 +279,13 @@
|
||||
{ method: 'POST' }
|
||||
)
|
||||
sendUserToast(`Rebuild queued for ${name}:${tag}`)
|
||||
detailDrawer?.closeDrawer()
|
||||
loadSnapshots()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to rebuild snapshot: ${e.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSnapshot() {
|
||||
if (!uploadFile || !uploadName) return
|
||||
uploading = true
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/w/${$workspaceStore}/sandbox/snapshots/${encodeURIComponent(uploadName)}/${encodeURIComponent(uploadTag || 'latest')}/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: uploadFile
|
||||
}
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error((await resp.text()) || resp.statusText)
|
||||
}
|
||||
sendUserToast(`Snapshot ${uploadName}:${uploadTag || 'latest'} uploaded successfully`)
|
||||
uploadName = ''
|
||||
uploadTag = 'latest'
|
||||
uploadFile = null
|
||||
uploadDrawer?.closeDrawer()
|
||||
loadSnapshots()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Upload failed: ${e.message}`, true)
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createVolume(close: () => void) {
|
||||
try {
|
||||
await apiFetch('/volumes', {
|
||||
@@ -240,53 +345,138 @@
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-refresh snapshots while any are pending or building
|
||||
$effect(() => {
|
||||
const hasPending = snapshots?.some(
|
||||
(s) => s.status === 'pending' || s.status === 'building'
|
||||
)
|
||||
if (!hasPending) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadSnapshots()
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={uploadDrawer}>
|
||||
<DrawerContent title="Upload Snapshot" on:close={uploadDrawer?.closeDrawer}>
|
||||
<Drawer bind:this={createDrawer}>
|
||||
<DrawerContent title="New Snapshot" on:close={createDrawer?.closeDrawer}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-secondary text-xs">
|
||||
Upload a pre-built rootfs tar.gz file to use as a sandbox snapshot. You can create one
|
||||
using:
|
||||
</p>
|
||||
<pre
|
||||
class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto"
|
||||
>crane export python:3.11-slim - | gzip > snapshot.tar.gz</pre>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Name</span>
|
||||
<input
|
||||
class="w-full mt-1"
|
||||
bind:value={uploadName}
|
||||
placeholder="e.g. python-env"
|
||||
/>
|
||||
<input class="w-full mt-1" bind:value={newName} placeholder="e.g. python-env" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Tag</span>
|
||||
<input class="w-full mt-1" bind:value={uploadTag} placeholder="latest" />
|
||||
<input class="w-full mt-1" bind:value={newTag} placeholder="latest" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Snapshot file (.tar.gz)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
class="w-full mt-1"
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
uploadFile = target.files?.[0] ?? null
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{#if uploadFile}
|
||||
<p class="text-xs text-secondary">
|
||||
File: {uploadFile.name} ({formatSize(uploadFile.size)})
|
||||
|
||||
<ToggleButtonGroup bind:selected={snapshotMode}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="builder" label="Builder" {item} />
|
||||
<ToggleButton value="dockerfile" label="Dockerfile" {item} />
|
||||
<ToggleButton value="upload" label="Upload" icon={Upload} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{#if snapshotMode === 'builder'}
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Docker image</span>
|
||||
<input
|
||||
class="w-full mt-1"
|
||||
bind:value={newDockerImage}
|
||||
placeholder="e.g. python:3.11-slim"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Setup script (optional)</span>
|
||||
<textarea
|
||||
class="w-full mt-1 text-xs font-mono"
|
||||
rows="4"
|
||||
bind:value={newSetupScript}
|
||||
placeholder="pip install numpy pandas"
|
||||
></textarea>
|
||||
</label>
|
||||
{:else if snapshotMode === 'dockerfile'}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold">Dockerfile</span>
|
||||
<textarea
|
||||
class="w-full text-xs font-mono bg-surface-secondary rounded p-3"
|
||||
rows="8"
|
||||
bind:value={dockerfileContent}
|
||||
placeholder={"FROM python:3.11-slim\nRUN pip install numpy pandas"}
|
||||
></textarea>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
bind:this={dockerfileFileInput}
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
file.text().then((text) => {
|
||||
dockerfileContent = text
|
||||
})
|
||||
}
|
||||
target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: Upload }}
|
||||
on:click={() => dockerfileFileInput?.click()}
|
||||
>
|
||||
Load from file
|
||||
</Button>
|
||||
{#if dockerfileWarnings.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each dockerfileWarnings as warning}
|
||||
<Badge color="yellow" small>{warning}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-secondary text-xs">
|
||||
Upload a pre-built rootfs tar.gz file. You can create one using:
|
||||
</p>
|
||||
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto"
|
||||
>crane export python:3.11-slim - | gzip > snapshot.tar.gz</pre>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Snapshot file (.tar.gz)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
class="w-full mt-1"
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
uploadFile = target.files?.[0] ?? null
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{#if uploadFile}
|
||||
<p class="text-xs text-secondary">
|
||||
File: {uploadFile.name} ({formatSize(uploadFile.size)})
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!uploadName || !uploadFile || uploading}
|
||||
on:click={uploadSnapshot}
|
||||
disabled={!newName ||
|
||||
(snapshotMode === 'builder' && !newDockerImage) ||
|
||||
(snapshotMode === 'dockerfile' && !dockerfileContent.trim()) ||
|
||||
(snapshotMode === 'upload' && (!uploadFile || uploading))}
|
||||
on:click={createSnapshot}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
{#if snapshotMode === 'upload' && uploading}
|
||||
Uploading...
|
||||
{:else}
|
||||
Create
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
@@ -305,43 +495,53 @@
|
||||
|
||||
<section>
|
||||
<h3 class="font-semibold text-base mb-2">1. Create a Snapshot</h3>
|
||||
<p class="text-secondary mb-2">Export a Docker image as a tar.gz rootfs:</p>
|
||||
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
|
||||
# Install crane (Go required)
|
||||
go install github.com/google/go-containerregistry/cmd/crane@latest
|
||||
|
||||
# Export a Docker image to tar.gz
|
||||
crane export python:3.11-slim - | gzip > python-slim.tar.gz
|
||||
|
||||
# Or use docker directly
|
||||
docker create --name tmp python:3.11-slim
|
||||
docker export tmp | gzip > python-slim.tar.gz
|
||||
docker rm tmp</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-semibold text-base mb-2">2. Upload the Snapshot</h3>
|
||||
<p class="text-secondary">
|
||||
Use the "Upload Snapshot" button to upload the tar.gz file. It will be stored in your
|
||||
configured S3 object store and marked as "ready".
|
||||
<p class="text-secondary mb-2">
|
||||
Click "New snapshot" to open the creation drawer. Three modes are available:
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-xs mb-1">Builder</p>
|
||||
<p class="text-secondary text-xs">
|
||||
Specify a Docker image (e.g. <code class="text-2xs">python:3.11-slim</code>) and an
|
||||
optional setup script. Windmill pulls the image and runs the script to build the
|
||||
snapshot on a worker.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-xs mb-1">Dockerfile</p>
|
||||
<p class="text-secondary text-xs">
|
||||
Paste or upload a Dockerfile. <code class="text-2xs">FROM</code> sets the base image;
|
||||
<code class="text-2xs">RUN</code>, <code class="text-2xs">ENV</code>, and
|
||||
<code class="text-2xs">WORKDIR</code> are converted to a setup script.
|
||||
Unsupported instructions (COPY, ADD, etc.) show a warning but don't block creation.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-xs mb-1">Upload</p>
|
||||
<p class="text-secondary text-xs">
|
||||
Upload a pre-built rootfs <code class="text-2xs">.tar.gz</code> file. You can create
|
||||
one with:
|
||||
</p>
|
||||
<pre class="bg-surface-secondary rounded p-2 text-xs overflow-x-auto mt-1">crane export python:3.11-slim - | gzip > snapshot.tar.gz</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-semibold text-base mb-2">3. Use in Scripts</h3>
|
||||
<h3 class="font-semibold text-base mb-2">2. Use in Scripts</h3>
|
||||
<p class="text-secondary mb-2">
|
||||
Reference snapshots and volumes using comment annotations in your script:
|
||||
</p>
|
||||
<p class="text-xs font-medium mb-1">Python / Bash</p>
|
||||
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
|
||||
# Python / Bash
|
||||
# sandbox: python-env:latest
|
||||
# volume: data:/workspace/data
|
||||
|
||||
def main():
|
||||
import pandas as pd # available from snapshot
|
||||
# /workspace/data persists between runs</pre>
|
||||
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto mt-2">
|
||||
// TypeScript / Go
|
||||
<p class="text-xs font-medium mb-1 mt-2">TypeScript / Go</p>
|
||||
<pre class="bg-surface-secondary rounded p-3 text-xs overflow-x-auto">
|
||||
// sandbox: node-env:v2
|
||||
// volume: cache:/tmp/cache
|
||||
|
||||
@@ -387,6 +587,110 @@ export async function main() {
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={detailDrawer}>
|
||||
<DrawerContent
|
||||
title={selectedSnapshot ? `${selectedSnapshot.name}:${selectedSnapshot.tag}` : ''}
|
||||
on:close={detailDrawer?.closeDrawer}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if selectedSnapshot}
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
on:click={() => rebuildSnapshot(selectedSnapshot!.name, selectedSnapshot!.tag)}
|
||||
>
|
||||
Rebuild
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => deleteSnapshot(selectedSnapshot!.name, selectedSnapshot!.tag)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if selectedSnapshot}
|
||||
<div class="flex flex-col gap-6 text-sm">
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
|
||||
<span class="text-secondary text-xs">Status</span>
|
||||
<Badge color={statusColor(selectedSnapshot.status)} small>
|
||||
{selectedSnapshot.status}
|
||||
</Badge>
|
||||
|
||||
<span class="text-secondary text-xs">Size</span>
|
||||
<span class="text-xs">{formatSize(selectedSnapshot.size_bytes)}</span>
|
||||
|
||||
<span class="text-secondary text-xs">Created</span>
|
||||
<span class="text-xs">
|
||||
{selectedSnapshot.created_by} · {displayDate(selectedSnapshot.created_at)}
|
||||
</span>
|
||||
|
||||
<span class="text-secondary text-xs">Updated</span>
|
||||
<span class="text-xs">{displayDate(selectedSnapshot.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h4 class="font-semibold text-xs mb-2">Build Configuration</h4>
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
|
||||
<span class="text-secondary text-xs">Docker Image</span>
|
||||
<span class="text-xs font-mono">{selectedSnapshot.docker_image}</span>
|
||||
|
||||
<span class="text-secondary text-xs">Setup Script</span>
|
||||
<div>
|
||||
{#if selectedSnapshot.setup_script}
|
||||
<pre class="bg-surface-secondary rounded p-2 text-xs font-mono whitespace-pre-wrap">{selectedSnapshot.setup_script}</pre>
|
||||
{:else}
|
||||
<span class="text-xs text-tertiary">None</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedSnapshot.build_job_id || selectedSnapshot.build_error}
|
||||
<section>
|
||||
<h4 class="font-semibold text-xs mb-2">Build Job</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if selectedSnapshot.build_job_id}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-secondary text-xs">Job ID</span>
|
||||
<a
|
||||
href="{base}/run/{selectedSnapshot.build_job_id}?workspace={$workspaceStore}"
|
||||
class="text-xs font-mono text-blue-500 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{selectedSnapshot.build_job_id}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedSnapshot.build_error}
|
||||
<div>
|
||||
<span class="text-secondary text-xs">Error</span>
|
||||
<pre class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded p-2 text-xs font-mono whitespace-pre-wrap mt-1">{selectedSnapshot.build_error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h4 class="font-semibold text-xs mb-2">Storage</h4>
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-baseline">
|
||||
<span class="text-secondary text-xs">S3 Key</span>
|
||||
<span class="text-xs font-mono text-secondary break-all">{selectedSnapshot.s3_key}</span>
|
||||
|
||||
<span class="text-secondary text-xs">Content Hash</span>
|
||||
<span class="text-xs font-mono text-secondary break-all">{selectedSnapshot.content_hash || '-'}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
@@ -409,66 +713,13 @@ export async function main() {
|
||||
</Button>
|
||||
{#if tab === 'snapshots'}
|
||||
<Button
|
||||
variant="border"
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Upload }}
|
||||
on:click={() => uploadDrawer?.openDrawer()}
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => createDrawer?.openDrawer()}
|
||||
>
|
||||
Upload snapshot
|
||||
New snapshot
|
||||
</Button>
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
||||
contentClasses="flex flex-col gap-2 p-4 w-80"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="accent" unifiedSize="md" startIcon={{ icon: Plus }} nonCaptureEvent>
|
||||
New snapshot
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Name</span>
|
||||
<input
|
||||
class="w-full mt-1"
|
||||
bind:value={newSnapshotName}
|
||||
placeholder="e.g. python-env"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Tag</span>
|
||||
<input
|
||||
class="w-full mt-1"
|
||||
bind:value={newSnapshotTag}
|
||||
placeholder="latest"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Docker image</span>
|
||||
<input
|
||||
class="w-full mt-1"
|
||||
bind:value={newSnapshotDockerImage}
|
||||
placeholder="e.g. python:3.11-slim"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold">Setup script (optional)</span>
|
||||
<textarea
|
||||
class="w-full mt-1 text-xs"
|
||||
rows="3"
|
||||
bind:value={newSnapshotSetupScript}
|
||||
placeholder="pip install numpy pandas"
|
||||
></textarea>
|
||||
</label>
|
||||
<Button
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!newSnapshotName || !newSnapshotDockerImage}
|
||||
on:click={() => createSnapshot(close)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
||||
@@ -541,7 +792,7 @@ export async function main() {
|
||||
</tr>
|
||||
{:else}
|
||||
{#each snapshots as snapshot (snapshot.name + ':' + snapshot.tag)}
|
||||
<Row hoverable>
|
||||
<Row hoverable on:click={() => openSnapshotDetail(snapshot)}>
|
||||
<Cell first>
|
||||
<span class="text-emphasis text-xs font-semibold">{snapshot.name}</span>
|
||||
</Cell>
|
||||
@@ -549,14 +800,29 @@ export async function main() {
|
||||
<span class="text-xs font-mono">{snapshot.tag}</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Badge color={statusColor(snapshot.status)} small>
|
||||
{snapshot.status}
|
||||
</Badge>
|
||||
{#if snapshot.build_error}
|
||||
<span class="text-red-500 text-2xs ml-1" title={snapshot.build_error}>
|
||||
(error)
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if snapshot.build_job_id}
|
||||
<a href="{base}/run/{snapshot.build_job_id}?workspace={$workspaceStore}">
|
||||
<Badge color={statusColor(snapshot.status)} small>
|
||||
{snapshot.status}
|
||||
</Badge>
|
||||
</a>
|
||||
{:else}
|
||||
<Badge color={statusColor(snapshot.status)} small>
|
||||
{snapshot.status}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if snapshot.build_error}
|
||||
<Tooltip small>
|
||||
{#snippet text()}
|
||||
<pre class="whitespace-pre-wrap text-2xs max-w-md">{snapshot.build_error}</pre>
|
||||
{/snippet}
|
||||
<span class="text-red-500 text-2xs cursor-help underline decoration-dotted">
|
||||
(error)
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-xs font-mono">{snapshot.docker_image}</span>
|
||||
@@ -573,6 +839,15 @@ export async function main() {
|
||||
<Cell shouldStopPropagation>
|
||||
<Dropdown
|
||||
items={[
|
||||
...(snapshot.build_job_id
|
||||
? [
|
||||
{
|
||||
displayName: 'View build job',
|
||||
icon: ExternalLink,
|
||||
href: `${base}/run/${snapshot.build_job_id}?workspace=${$workspaceStore}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Rebuild',
|
||||
icon: RefreshCw,
|
||||
|
||||
Reference in New Issue
Block a user