diff --git a/data/Dockerfiles/dovecot/imapsync_runner.pl b/data/Dockerfiles/dovecot/imapsync_runner.pl index e16534f8c..7f794a111 100644 --- a/data/Dockerfiles/dovecot/imapsync_runner.pl +++ b/data/Dockerfiles/dovecot/imapsync_runner.pl @@ -7,16 +7,10 @@ use Data::Dumper qw(Dumper); use IPC::Run 'run'; use File::Temp; use Try::Tiny; +use POSIX (); use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT); sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s }; -my $t = Proc::ProcessTable->new; -my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table}; -if ($imapsync_running ge 1) -{ - print "imapsync is active, exiting..."; - exit; -} sub qqw($) { my @params = (); @@ -35,6 +29,19 @@ sub qqw($) { return @params; } +# Move a finished job to the back of the queue, keeping order_id contiguous (1..N). +# Called only from the parent after reaping a child, so all rotations are serialized. +sub rotate_to_back { + my ($dbh, $jid) = @_; + return unless defined $jid; + my ($cnt) = $dbh->selectrow_array("SELECT COUNT(*) FROM imapsync"); + my ($old) = $dbh->selectrow_array("SELECT order_id FROM imapsync WHERE id = ?", undef, $jid); + return unless (defined $cnt && defined $old && $old < $cnt); + # close the gap left behind, then place this job last + $dbh->do("UPDATE imapsync SET order_id = order_id - 1 WHERE order_id > ? ORDER BY order_id ASC", undef, $old); + $dbh->do("UPDATE imapsync SET order_id = ? WHERE id = ?", undef, $cnt, $jid); +} + $run_dir="/tmp"; $dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock'; $lock_file = $run_dir . "/imapsync_busy"; @@ -46,6 +53,16 @@ $dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, { }); $dbh->do("UPDATE imapsync SET is_running = 0"); +# Max concurrent imapsync processes (admin-configurable, stored in MySQL). Fallback 1. +my $max_parallel = 1; +my ($mp) = $dbh->selectrow_array("SELECT value FROM imapsync_settings WHERE name = 'max_parallel'"); +$max_parallel = int($mp) if defined($mp) && int($mp) >= 1; + +# Global per-process bandwidth cap in bytes/s (0 = off). Applied to every imapsync run below. +my $global_bw = 0; +my ($gbw) = $dbh->selectrow_array("SELECT value FROM imapsync_settings WHERE name = 'max_bytes_per_second'"); +$global_bw = int($gbw) if defined($gbw) && int($gbw) > 0; + sub sig_handler { # Send die to force exception in "run" die "sig_handler received signal, preparing to exit...\n"; @@ -91,12 +108,36 @@ my $sth = $dbh->prepare("SELECT UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60 OR i.last_run IS NULL) - ORDER BY i.last_run"); + ORDER BY i.order_id ASC, i.id ASC"); $sth->execute(); -my $row; +my @rows = @{$sth->fetchall_arrayref()}; +$sth->finish(); -while ($row = $sth->fetchrow_arrayref()) { +# Fork pool: run up to $max_parallel imapsync children at once, in order_id order. +my $active = 0; +my %pid_job; # child pid => syncjob id, so the parent can rotate it to the back when it finishes +foreach my $row (@rows) { + # Throttle: once max children are running, reap one before starting the next + if ($active >= $max_parallel) { + my $done = wait(); + my $rc = $? >> 8; + $active-- if $active > 0; + if (defined $pid_job{$done}) { + rotate_to_back($dbh, $pid_job{$done}) if $rc == 0; # rotate only jobs that actually ran + delete $pid_job{$done}; + } + } + my $pid = fork(); + next if (!defined $pid); + if ($pid != 0) { $pid_job{$pid} = @$row[0]; $active++; next; } # parent: remember pid->job, continue + + # ---- CHILD ---- own DB connection so the parent's handle stays intact across forks + $dbh->{InactiveDestroy} = 1; + $dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, { + mysql_auto_reconnect => 1, + mysql_enable_utf8mb4 => 1 + }); $id = @$row[0]; $user1 = @$row[1]; @@ -151,7 +192,11 @@ while ($row = $sth->fetchrow_arrayref()) { || !defined($oauth_token_expires) || $oauth_token_expires - time() < 120) { my $upd = $dbh->prepare("UPDATE imapsync SET returned_text=?, success=0, exit_status='OAUTH_TOKEN_NOT_READY', last_run=NOW(), is_running=0 WHERE id=?"); $upd->execute("OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.", $id); - next; + $dbh->disconnect(); + unlink $passfile1->filename if defined $passfile1; + unlink $passfile2->filename if defined $passfile2; + # exit 75 (EX_TEMPFAIL): did not actually sync -> parent keeps its queue position (no rotate) + POSIX::_exit(75); } $tokenfile1 = File::Temp->new(TEMPLATE => $template); binmode($tokenfile1, ":utf8"); @@ -172,6 +217,12 @@ while ($row = $sth->fetchrow_arrayref()) { my @custom_params_a = qqw($custom_params); my $custom_params_ref = \@custom_params_a; + # Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit. + my $eff_bw = int($maxbytespersecond); + if ($global_bw > 0) { + $eff_bw = ($eff_bw > 0 && $eff_bw < $global_bw) ? $eff_bw : $global_bw; + } + my $generated_cmds = [ "/usr/local/bin/imapsync", "--tmpdir", "/tmp", "--nofoldersizes", @@ -181,7 +232,7 @@ while ($row = $sth->fetchrow_arrayref()) { ($exclude eq "" ? () : ("--exclude", $exclude)), ($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)), ($maxage eq "0" ? () : ('--maxage', $maxage)), - ($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)), + ($eff_bw <= 0 ? () : ('--maxbytespersecond', $eff_bw)), ($delete2duplicates ne "1" ? () : ('--delete2duplicates')), ($subscribeall ne "1" ? () : ('--subscribeall')), ($delete1 ne "1" ? () : ('--delete')), @@ -231,10 +282,25 @@ while ($row = $sth->fetchrow_arrayref()) { $update->execute(); }; + $dbh->disconnect(); + # Remove this child's own temp files, then _exit so END/DESTROY are skipped — this keeps + # the parent's LockFile lock and DB handle intact (a plain exit() would run autoclean and + # release the shared lock while the parent + siblings are still working). + unlink $passfile1->filename if defined $passfile1; + unlink $passfile2->filename if defined $passfile2; + unlink $tokenfile1->filename if defined $tokenfile1; + POSIX::_exit(0); # ---- end CHILD ---- +} +# Parent: wait for all remaining children, rotating each finished job to the back +while ((my $done = wait()) != -1) { + my $rc = $? >> 8; + if (defined $pid_job{$done}) { + rotate_to_back($dbh, $pid_job{$done}) if $rc == 0; # rotate only jobs that actually ran + delete $pid_job{$done}; + } } -$sth->finish(); $dbh->disconnect(); $lockmgr->unlock($lock_file); diff --git a/data/web/admin/system.php b/data/web/admin/system.php index 4db40c753..b736a5368 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -116,6 +116,7 @@ $template_data = [ 'ip_check' => customize('get', 'ip_check'), 'custom_login' => customize('get', 'custom_login'), 'password_complexity' => password_complexity('get'), + 'imapsync_settings' => imapsync_get_settings(), 'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'], 'cors_settings' => $cors_settings, 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 2b34e87a0..06ef29ea6 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1615,7 +1615,7 @@ paths: enc1: SSL auth_type: PLAIN active: 1 - is_global: 0 + scope: all msg: - imapsync_source_added - Example import @@ -1659,7 +1659,7 @@ paths: enc1: SSL auth_type: PLAIN active: "1" - is_global: "0" + scope: all properties: name: description: source name, unique per owner @@ -1688,9 +1688,21 @@ paths: - LOGIN - CRAM-MD5 - XOAUTH2 + oauth_flow: + description: XOAUTH2 grant type (default client_credentials) + type: string + enum: + - client_credentials + - authorization_code oauth_token_endpoint: description: required when auth_type is XOAUTH2 (token URL) type: string + oauth_authorize_endpoint: + description: required when oauth_flow is authorization_code (authorize URL) + type: string + oauth_userinfo_endpoint: + description: optional identity endpoint for authorization_code + type: string oauth_client_id: description: required when auth_type is XOAUTH2 type: string @@ -1706,12 +1718,25 @@ paths: active: description: enables or disables the source type: boolean - is_global: - description: admin-only — make the source available to everyone - type: boolean - owner: - description: admin-only — assign ownership to a specific username + scope: + description: >- + admin/domainadmin — source visibility. client_credentials + XOAUTH2 sources are always forced to a private scope. type: string + enum: + - all + - domain + - user + domains: + description: target domains when scope is domain + type: array + items: + type: string + users: + description: target users when scope is user + type: array + items: + type: string type: object /api/v1/add/tls-policy-map: post: @@ -4196,8 +4221,20 @@ paths: - LOGIN - CRAM-MD5 - XOAUTH2 + oauth_flow: + description: XOAUTH2 grant type + type: string + enum: + - client_credentials + - authorization_code oauth_token_endpoint: type: string + oauth_authorize_endpoint: + description: required when oauth_flow is authorization_code + type: string + oauth_userinfo_endpoint: + description: optional identity endpoint for authorization_code + type: string oauth_client_id: type: string oauth_client_secret: @@ -4209,6 +4246,23 @@ paths: type: string active: type: boolean + scope: + description: source visibility (admin/domainadmin) + type: string + enum: + - all + - domain + - user + domains: + description: target domains when scope is domain + type: array + items: + type: string + users: + description: target users when scope is user + type: array + items: + type: string type: object items: description: list of source ids to update @@ -4278,6 +4332,135 @@ paths: items: type: string type: object + /api/v1/edit/syncjob/order: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - edit + - job_order + msg: + - imapsync_order_updated + - "1" + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Move a sync job to a global queue position (admin only). Positions are + contiguous (1..N) across all sync jobs; setting a job to a position + shifts the others accordingly. The runner processes jobs in ascending + order, up to the configured `max_parallel` at a time, and rotates a job + to the back after it runs. + operationId: Set sync job queue position + summary: Set sync job queue position + requestBody: + content: + application/json: + schema: + example: + items: "3" + attr: + order_id: "1" + properties: + attr: + properties: + order_id: + description: target position (1..N) + type: number + type: object + items: + description: sync job id to move + type: string + type: object + /api/v1/edit/imapsync_settings: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - imapsync_settings + - edit + msg: + - max_parallel_saved + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Update global sync job settings (admin only). `max_parallel` is the + number of imapsync processes that may run concurrently; `max_kb_per_second` + is a per-process bandwidth cap in KB/s (0 = unlimited), stored internally + in bytes/s. + operationId: Update sync job settings + summary: Update sync job settings + requestBody: + content: + application/json: + schema: + example: + attr: + max_parallel: "4" + max_kb_per_second: "2000" + properties: + attr: + properties: + max_parallel: + description: Max. concurrent sync processes (>= 1) + type: number + max_kb_per_second: + description: Bandwidth limit per process in KB/s (0 = unlimited) + type: number + type: object + type: object /api/v1/edit/user-acl: post: responses: @@ -6005,7 +6188,8 @@ paths: - id: 3 name: Example import description: "" - owner: null + created_by: "" + scope: all host1: imap.example.org port1: 993 enc1: SSL diff --git a/data/web/inc/functions.syncjob.inc.php b/data/web/inc/functions.syncjob.inc.php index 12f048117..cb8bb7129 100644 --- a/data/web/inc/functions.syncjob.inc.php +++ b/data/web/inc/functions.syncjob.inc.php @@ -341,6 +341,75 @@ function imapsync_source_refresh_user_token_internal($source_id, $username) { return true; } +function imapsync_get_setting($name, $default = null) { + // Global syncjob setting (also read by the Perl runner directly from MySQL). + global $pdo; + $stmt = $pdo->prepare("SELECT `value` FROM `imapsync_settings` WHERE `name` = :name"); + $stmt->execute(array(':name' => $name)); + $v = $stmt->fetchColumn(); + return ($v === false) ? $default : $v; +} + +function imapsync_get_settings() { + // All global syncjob settings as name => value, with defaults for keys not yet stored. + global $pdo; + $settings = array('max_parallel' => 1, 'max_bytes_per_second' => 0); + foreach ($pdo->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR) as $k => $v) { + $settings[$k] = $v; + } + return $settings; +} + +function imapsync_set_setting($name, $value) { + // Admin-only writer for a global syncjob setting. + global $pdo; + if (($_SESSION['mailcow_cc_role'] ?? '') !== 'admin') return false; + $stmt = $pdo->prepare("INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"); + $stmt->execute(array(':name' => $name, ':value' => (string)$value)); + return true; +} + +function imapsync_set_order($id, $pos) { + // Move syncjob $id to global queue position $pos (1..N), shifting only the rows between + // the old and new position (range-shift, not a full renumber). Admin-only, transactional. + global $pdo; + if (($_SESSION['mailcow_cc_role'] ?? '') !== 'admin' || !is_numeric($id)) return false; + $pos = intval($pos); + try { + $pdo->beginTransaction(); + $cnt = intval($pdo->query("SELECT COUNT(*) FROM `imapsync`")->fetchColumn()); + if ($cnt === 0) { $pdo->commit(); return false; } + if ($pos < 1) $pos = 1; + if ($pos > $cnt) $pos = $cnt; + $stmt = $pdo->prepare("SELECT `order_id` FROM `imapsync` WHERE `id` = :id"); + $stmt->execute(array(':id' => $id)); + $old = $stmt->fetchColumn(); + if ($old === false) { $pdo->rollBack(); return false; } + $old = intval($old); + if ($old !== $pos) { + if ($pos < $old) { + // moving up: shift the [pos, old-1] band down (+1), highest first to avoid collisions + $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` + 1 + WHERE `order_id` >= :pos AND `order_id` < :old ORDER BY `order_id` DESC") + ->execute(array(':pos' => $pos, ':old' => $old)); + } else { + // moving down: shift the (old, pos] band up (-1), lowest first + $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` - 1 + WHERE `order_id` > :old AND `order_id` <= :pos ORDER BY `order_id` ASC") + ->execute(array(':old' => $old, ':pos' => $pos)); + } + $pdo->prepare("UPDATE `imapsync` SET `order_id` = :pos WHERE `id` = :id") + ->execute(array(':pos' => $pos, ':id' => $id)); + } + $pdo->commit(); + return true; + } catch (Exception $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + return false; + } +} + function syncjob($_action, $_type, $_data = null, $_extra = null) { global $pdo; global $lang; @@ -513,8 +582,9 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { ); return false; } - $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `source_id`, `user1`, `password1`, `mins_interval`, `delete2duplicates`, `custom_params`, `active`) - VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :source_id, :user1, :password1, :mins_interval, :delete2duplicates, :custom_params, :active)"); + $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `source_id`, `user1`, `password1`, `mins_interval`, `delete2duplicates`, `custom_params`, `active`, `order_id`) + VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :source_id, :user1, :password1, :mins_interval, :delete2duplicates, :custom_params, :active, + (SELECT COALESCE(MAX(o.`order_id`), 0) + 1 FROM (SELECT `order_id` FROM `imapsync`) o))"); $stmt->execute(array( ':user2' => $username, ':custom_params' => $custom_params, @@ -723,6 +793,26 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { break; case 'edit': switch ($_type) { + case 'job_order': + // Set a syncjob's global queue position (admin-only). $_data = {id, order_id} + $id = is_array($_data) ? ($_data['id'] ?? null) : null; + if (is_array($id)) $id = reset($id); + $pos = is_array($_data) ? ($_data['order_id'] ?? null) : null; + if (!is_numeric($id) || !is_numeric($pos) || imapsync_set_order($id, $pos) === false) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log), + 'msg' => array('imapsync_order_updated', intval($pos)) + ); + return true; + break; case 'job': if (!is_array($_data['id'])) { $ids = array(); @@ -1097,8 +1187,17 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { ); continue; } + // Read the position first so we can close the gap in the global queue afterwards + $ord = $pdo->prepare("SELECT `order_id` FROM `imapsync` WHERE `id` = :id"); + $ord->execute(array(':id' => $id)); + $deleted_pos = $ord->fetchColumn(); $stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id"); $stmt->execute(array(':id' => $id)); + if ($deleted_pos !== false) { + $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` - 1 + WHERE `order_id` > :pos ORDER BY `order_id` ASC") + ->execute(array(':pos' => intval($deleted_pos))); + } $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), @@ -1206,7 +1305,7 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { else { $_data = $_SESSION['mailcow_cc_username']; } - $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username"); + $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username ORDER BY `order_id`"); $stmt->execute(array(':username' => $_data)); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while($row = array_shift($rows)) { diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 2046fb75e..0751c511d 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "15072026_1000"; + $db_version = "16072026_1000"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -892,6 +892,7 @@ function init_db_schema() "subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'", "dry" => "TINYINT(1) NOT NULL DEFAULT '0'", "is_running" => "TINYINT(1) NOT NULL DEFAULT '0'", + "order_id" => "INT NOT NULL DEFAULT 0", "returned_text" => "LONGTEXT", "last_run" => "TIMESTAMP NULL DEFAULT NULL", "success" => "TINYINT(1) UNSIGNED DEFAULT NULL", @@ -905,7 +906,8 @@ function init_db_schema() "" => array("id") ), "key" => array( - "idx_source_id" => array("source_id") + "idx_source_id" => array("source_id"), + "idx_order_id" => array("order_id") ), "fkey" => array( "fk_imapsync_source" => array( @@ -918,6 +920,20 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "imapsync_settings" => array( + "cols" => array( + "name" => "VARCHAR(64) NOT NULL", + "value" => "VARCHAR(255) NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("name") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "bcc_maps" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", @@ -1509,6 +1525,14 @@ function init_db_schema() // Migrate webauthn tfa $stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')"); + // Syncjobs: seed global settings + one-time sequential order_id for pre-existing jobs + $pdo->query("INSERT IGNORE INTO `imapsync_settings` (`name`, `value`) VALUES ('max_parallel', '1')"); + if (intval($pdo->query("SELECT COUNT(*) FROM `imapsync`")->fetchColumn()) > 0 + && intval($pdo->query("SELECT MAX(`order_id`) FROM `imapsync`")->fetchColumn()) === 0) { + $pdo->query("SET @r := 0"); + $pdo->query("UPDATE `imapsync` SET `order_id` = (@r := @r + 1) ORDER BY `id`"); + } + // Inject admin if not exists $stmt = $pdo->query("SELECT NULL FROM `admin`"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 3b5dec204..f715b53a5 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -2157,7 +2157,7 @@ jQuery(function($){ "tr" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", language: lang_datatables, - order: [[2, 'desc']], + order: [[3, 'asc']], initComplete: function(){ hideTableExpandCollapseBtn('#tab-syncjobs', '#sync_job_table'); }, @@ -2228,6 +2228,12 @@ jQuery(function($){ responsivePriority: 3, defaultContent: '' }, + { + title: lang.syncjobs.order_id, + data: 'order_id', + responsivePriority: 3, + defaultContent: '' + }, { title: lang.owner, data: 'user2', diff --git a/data/web/js/site/user.js b/data/web/js/site/user.js index 26cc821fc..77064f8ec 100644 --- a/data/web/js/site/user.js +++ b/data/web/js/site/user.js @@ -272,6 +272,7 @@ jQuery(function($){ "tr" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", language: lang_datatables, + order: [[3, 'asc']], ajax: { type: "GET", url: '/api/v1/get/syncjobs/' + encodeURIComponent(mailcow_cc_username) + '/no_log', @@ -345,6 +346,12 @@ jQuery(function($){ defaultContent: '', responsivePriority: 3 }, + { + title: lang.syncjobs.order_id, + data: 'order_id', + defaultContent: '', + responsivePriority: 3 + }, { title: 'Server', data: 'server_w_port', diff --git a/data/web/json_api.php b/data/web/json_api.php index 6c20fa1de..912fa3cc3 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -2001,7 +2001,29 @@ if (isset($_GET['query'])) { } break; case "syncjob": - process_edit_return(syncjob('edit', 'job', array_merge(array('id' => $items), $attr))); + switch ($object) { + case "order": + process_edit_return(syncjob('edit', 'job_order', array_merge(array('id' => $items), $attr))); + break; + default: + process_edit_return(syncjob('edit', 'job', array_merge(array('id' => $items), $attr))); + break; + } + break; + case "imapsync_settings": + $mp = intval($attr['max_parallel'] ?? 0); + // UI sends KB/s; store bytes/s (runner + per-job field are bytes/s) + $bw = max(0, intval($attr['max_kb_per_second'] ?? 0)) * 1024; + if ($mp < 1) { + $_SESSION['return'][] = array('type' => 'danger', 'log' => array('imapsync_settings', 'edit', $attr), 'msg' => 'imapsync_max_parallel_invalid'); + process_edit_return(false); + } elseif (imapsync_set_setting('max_parallel', $mp) && imapsync_set_setting('max_bytes_per_second', $bw)) { + $_SESSION['return'][] = array('type' => 'success', 'log' => array('imapsync_settings', 'edit'), 'msg' => 'max_parallel_saved'); + process_edit_return(true); + } else { + $_SESSION['return'][] = array('type' => 'danger', 'log' => array('imapsync_settings', 'edit'), 'msg' => 'access_denied'); + process_edit_return(false); + } break; case "syncjob_source": switch ($object) { diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 9db362a5b..60d84eaa5 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -397,7 +397,12 @@ "logo_dark_label": "Invertiert für den Darkmode", "logo_normal_label": "Normal", "user_link": "Nutzer-Link", - "filter": "Filter" + "filter": "Filter", + "syncjobs": "Sync Jobs", + "syncjob_max_parallel": "Max. parallele Sync-Prozesse", + "syncjob_max_parallel_info": "Wie viele Sync-Jobs gleichzeitig laufen dürfen.", + "syncjob_max_kb_per_second": "Bandbreiten-Limit pro Prozess (KB/s)", + "syncjob_max_kb_per_second_info": "0 = unbegrenzt. Kappt jeden Sync-Prozess; ein per-Job-Wert wird hierdurch begrenzt. Gesamt ≈ dieser Wert × parallele Prozesse." }, "danger": { "access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten", @@ -559,7 +564,8 @@ "imapsync_source_oauth_endpoint_invalid": "Ungültige OAuth2-Endpoint-URL", "imapsync_source_oauth_extra_invalid": "OAuth2-Extra-Parameter müssen gültiges JSON sein", "imapsync_source_invalid": "Ungültige Daten der Sync-Quelle", - "imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration" + "imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration", + "imapsync_max_parallel_invalid": "Anzahl paralleler Prozesse muss mindestens 1 sein" }, "datatables": { "collapse_all": "Alle Einklappen", @@ -1188,7 +1194,9 @@ "imapsync_source_added": "Sync-Quelle %s wurde hinzugefügt", "imapsync_source_modified": "Sync-Quelle %s wurde gespeichert", "imapsync_source_deleted": "Sync-Quelle %s wurde gelöscht", - "imapsync_source_token_refreshed": "OAuth-Token für %s wurde erneuert" + "imapsync_source_token_refreshed": "OAuth-Token für %s wurde erneuert", + "max_parallel_saved": "Sync-Job-Einstellungen gespeichert", + "imapsync_order_updated": "Sync-Job auf Position %s verschoben" }, "tfa": { "authenticators": "Authentikatoren", @@ -1282,7 +1290,10 @@ "timeout2": "Timeout für Verbindung zum lokalen Host", "maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)", "custom_params": "Eigene Parameter", - "custom_params_hint": "Richtig: --param=xy, falsch: --param xy" + "custom_params_hint": "Richtig: --param=xy, falsch: --param xy", + "order_id": "Position", + "order_id_hint": "Kleinere Position läuft zuerst; Ändern sortiert die globale Warteschlange um.", + "set_position": "Position setzen" }, "user": { "action": "Aktion", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index eed99ae4f..db13344b0 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -397,7 +397,12 @@ "user_quicklink": "Hide Quicklink to User Login Page", "validate_license_now": "Validate GUID against license server", "verify": "Verify", - "yes": "✓" + "yes": "✓", + "syncjobs": "Sync jobs", + "syncjob_max_parallel": "Max. parallel sync processes", + "syncjob_max_parallel_info": "How many sync jobs may run at the same time.", + "syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)", + "syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process; a per-job value is capped by this. Total ≈ this × max parallel." }, "danger": { "access_denied": "Access denied or invalid form data", @@ -559,7 +564,8 @@ "imapsync_source_oauth_endpoint_invalid": "Invalid OAuth2 endpoint URL", "imapsync_source_oauth_extra_invalid": "OAuth2 extra parameters must be valid JSON", "imapsync_source_invalid": "Invalid sync source data", - "imapsync_source_oauth_invalid": "Invalid OAuth2 configuration" + "imapsync_source_oauth_invalid": "Invalid OAuth2 configuration", + "imapsync_max_parallel_invalid": "Parallel process count must be at least 1" }, "datatables": { "collapse_all": "Collapse All", @@ -1195,7 +1201,9 @@ "imapsync_source_added": "Sync source %s has been added", "imapsync_source_modified": "Sync source %s has been saved", "imapsync_source_deleted": "Sync source %s has been deleted", - "imapsync_source_token_refreshed": "OAuth token for %s has been refreshed" + "imapsync_source_token_refreshed": "OAuth token for %s has been refreshed", + "max_parallel_saved": "Sync job settings saved", + "imapsync_order_updated": "Sync job moved to position %s" }, "tfa": { "authenticators": "Authenticators", @@ -1289,7 +1297,10 @@ "timeout2": "Timeout for connection to local host", "maxbytespersecond": "Max. bytes per second
(0 = unlimited)", "custom_params": "Custom parameters", - "custom_params_hint": "Right: --param=xy, wrong: --param xy" + "custom_params_hint": "Right: --param=xy, wrong: --param xy", + "order_id": "Position", + "order_id_hint": "Lower position runs first; setting it re-sorts the global queue.", + "set_position": "Set position" }, "user": { "action": "Action", diff --git a/data/web/templates/admin.twig b/data/web/templates/admin.twig index 651107ccf..0c487c4ea 100644 --- a/data/web/templates/admin.twig +++ b/data/web/templates/admin.twig @@ -22,6 +22,7 @@
  • +
  • @@ -51,6 +52,7 @@ {% include 'admin/tab-config-f2b.twig' %} {% include 'admin/tab-config-quarantine.twig' %} {% include 'admin/tab-config-quota.twig' %} + {% include 'admin/tab-config-syncjob.twig' %} {% include 'admin/tab-config-rsettings.twig' %} {% include 'admin/tab-config-customize.twig' %} {% include 'admin/tab-config-password-settings.twig' %} diff --git a/data/web/templates/admin/tab-config-syncjob.twig b/data/web/templates/admin/tab-config-syncjob.twig new file mode 100644 index 000000000..111e4a26e --- /dev/null +++ b/data/web/templates/admin/tab-config-syncjob.twig @@ -0,0 +1,35 @@ +
    +
    +
    + + {{ lang.admin.syncjobs }} +
    +
    +
    +
    +
    + + + {{ lang.admin.syncjob_max_parallel_info }} +
    +
    + + + {{ lang.admin.syncjob_max_kb_per_second_info }} +
    +
    + +
    +
    +
    +
    diff --git a/data/web/templates/edit/syncjob.twig b/data/web/templates/edit/syncjob.twig index fc7850b6e..d25f16da1 100644 --- a/data/web/templates/edit/syncjob.twig +++ b/data/web/templates/edit/syncjob.twig @@ -158,6 +158,23 @@ + {% if mailcow_cc_role == 'admin' %} +
    +
    +
    + +
    + + {{ lang.syncjobs.order_id_hint }} +
    +
    +
    +
    + +
    +
    +
    + {% endif %} {% else %} {{ parent() }} {% endif %}