From 9c9807ecc998e8943581ae20ee658f08a202e5bc Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Mon, 21 Sep 2020 21:32:30 +0200
Subject: [PATCH] Add admin setting to change max concurrent workers

---
 .../002_site_info_more_settings.down.sql      |   4 +
 .../002_site_info_more_settings.up.sql        |   3 +-
 api/graphql/generated.go                      | 175 +++++++++++-
 api/graphql/models/generated.go               |   2 +
 api/graphql/models/site_info.go               |   6 +-
 api/graphql/resolvers/scanner.go              |  22 ++
 api/graphql/schema.graphql                    |   5 +
 api/scanner/queue.go                          |  21 +-
 api/server.go                                 |   9 +-
 ui/src/Pages/SettingsPage/PeriodicScanner.js  | 237 +++++++++++++++++
 .../SettingsPage/ScannerConcurrentWorkers.js  |  80 ++++++
 ui/src/Pages/SettingsPage/ScannerSection.js   | 248 +-----------------
 ui/src/Pages/SettingsPage/SettingsPage.js     |  11 +
 ui/src/Pages/SettingsPage/UserRow.js          |   2 +
 14 files changed, 568 insertions(+), 257 deletions(-)
 create mode 100644 api/database/migrations/002_site_info_more_settings.down.sql
 create mode 100644 ui/src/Pages/SettingsPage/PeriodicScanner.js
 create mode 100644 ui/src/Pages/SettingsPage/ScannerConcurrentWorkers.js

diff --git a/api/database/migrations/002_site_info_more_settings.down.sql b/api/database/migrations/002_site_info_more_settings.down.sql
new file mode 100644
index 0000000..0605321
--- /dev/null
+++ b/api/database/migrations/002_site_info_more_settings.down.sql
@@ -0,0 +1,4 @@
+
+ALTER TABLE site_info
+  DROP COLUMN IF EXISTS periodic_scan_interval,
+  DrOP COLUMN IF EXISTS concurrent_workers;
diff --git a/api/database/migrations/002_site_info_more_settings.up.sql b/api/database/migrations/002_site_info_more_settings.up.sql
index 47db816..272bbfe 100644
--- a/api/database/migrations/002_site_info_more_settings.up.sql
+++ b/api/database/migrations/002_site_info_more_settings.up.sql
@@ -1,3 +1,4 @@
 
 ALTER TABLE site_info
-  ADD COLUMN IF NOT EXISTS periodic_scan_interval int(8) NOT NULL DEFAULT 0;
+  ADD COLUMN IF NOT EXISTS periodic_scan_interval int(8) NOT NULL DEFAULT 0,
+  ADD COLUMN IF NOT EXISTS concurrent_workers int(8) NOT NULL DEFAULT 3;
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 902ea53..5f3e7a7 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -114,20 +114,21 @@ type ComplexityRoot struct {
 	}
 
 	Mutation struct {
-		AuthorizeUser           func(childComplexity int, username string, password string) int
-		CreateUser              func(childComplexity int, username string, rootPath string, password *string, admin bool) int
-		DeleteShareToken        func(childComplexity int, token string) int
-		DeleteUser              func(childComplexity int, id int) int
-		FavoriteMedia           func(childComplexity int, mediaID int, favorite bool) int
-		InitialSetupWizard      func(childComplexity int, username string, password string, rootPath string) int
-		ProtectShareToken       func(childComplexity int, token string, password *string) int
-		RegisterUser            func(childComplexity int, username string, password string, rootPath string) int
-		ScanAll                 func(childComplexity int) int
-		ScanUser                func(childComplexity int, userID int) int
-		SetPeriodicScanInterval func(childComplexity int, interval int) int
-		ShareAlbum              func(childComplexity int, albumID int, expire *time.Time, password *string) int
-		ShareMedia              func(childComplexity int, mediaID int, expire *time.Time, password *string) int
-		UpdateUser              func(childComplexity int, id int, username *string, rootPath *string, password *string, admin *bool) int
+		AuthorizeUser               func(childComplexity int, username string, password string) int
+		CreateUser                  func(childComplexity int, username string, rootPath string, password *string, admin bool) int
+		DeleteShareToken            func(childComplexity int, token string) int
+		DeleteUser                  func(childComplexity int, id int) int
+		FavoriteMedia               func(childComplexity int, mediaID int, favorite bool) int
+		InitialSetupWizard          func(childComplexity int, username string, password string, rootPath string) int
+		ProtectShareToken           func(childComplexity int, token string, password *string) int
+		RegisterUser                func(childComplexity int, username string, password string, rootPath string) int
+		ScanAll                     func(childComplexity int) int
+		ScanUser                    func(childComplexity int, userID int) int
+		SetPeriodicScanInterval     func(childComplexity int, interval int) int
+		SetScannerConcurrentWorkers func(childComplexity int, workers int) int
+		ShareAlbum                  func(childComplexity int, albumID int, expire *time.Time, password *string) int
+		ShareMedia                  func(childComplexity int, mediaID int, expire *time.Time, password *string) int
+		UpdateUser                  func(childComplexity int, id int, username *string, rootPath *string, password *string, admin *bool) int
 	}
 
 	Notification struct {
@@ -178,6 +179,7 @@ type ComplexityRoot struct {
 	}
 
 	SiteInfo struct {
+		ConcurrentWorkers    func(childComplexity int) int
 		InitialSetup         func(childComplexity int) int
 		PeriodicScanInterval func(childComplexity int) int
 	}
@@ -243,6 +245,7 @@ type MutationResolver interface {
 	CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error)
 	DeleteUser(ctx context.Context, id int) (*models.User, error)
 	SetPeriodicScanInterval(ctx context.Context, interval int) (int, error)
+	SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error)
 }
 type QueryResolver interface {
 	SiteInfo(ctx context.Context) (*models.SiteInfo, error)
@@ -727,6 +730,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Mutation.SetPeriodicScanInterval(childComplexity, args["interval"].(int)), true
 
+	case "Mutation.setScannerConcurrentWorkers":
+		if e.complexity.Mutation.SetScannerConcurrentWorkers == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_setScannerConcurrentWorkers_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.SetScannerConcurrentWorkers(childComplexity, args["workers"].(int)), true
+
 	case "Mutation.shareAlbum":
 		if e.complexity.Mutation.ShareAlbum == nil {
 			break
@@ -1027,6 +1042,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.ShareToken.Token(childComplexity), true
 
+	case "SiteInfo.concurrentWorkers":
+		if e.complexity.SiteInfo.ConcurrentWorkers == nil {
+			break
+		}
+
+		return e.complexity.SiteInfo.ConcurrentWorkers(childComplexity), true
+
 	case "SiteInfo.initialSetup":
 		if e.complexity.SiteInfo.InitialSetup == nil {
 			break
@@ -1327,6 +1349,9 @@ type Mutation {
   a value of 0 will disable periodic scans
   """
   setPeriodicScanInterval(interval: Int!): Int!
+
+  "Set max number of concurrent scanner jobs running at once"
+  setScannerConcurrentWorkers(workers: Int!): Int!
 }
 
 type Subscription {
@@ -1387,6 +1412,8 @@ type SiteInfo {
   initialSetup: Boolean!
   "How often automatic scans should be initiated in seconds"
   periodicScanInterval: Int! @isAdmin
+  "How many max concurrent scanner jobs that should run at once"
+  concurrentWorkers: Int! @isAdmin
 }
 
 type User {
@@ -1783,6 +1810,21 @@ func (ec *executionContext) field_Mutation_setPeriodicScanInterval_args(ctx cont
 	return args, nil
 }
 
+func (ec *executionContext) field_Mutation_setScannerConcurrentWorkers_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 int
+	if tmp, ok := rawArgs["workers"]; ok {
+		ctx := graphql.WithFieldInputContext(ctx, graphql.NewFieldInputWithField("workers"))
+		arg0, err = ec.unmarshalNInt2int(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["workers"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Mutation_shareAlbum_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -4203,6 +4245,47 @@ func (ec *executionContext) _Mutation_setPeriodicScanInterval(ctx context.Contex
 	return ec.marshalNInt2int(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Mutation_setScannerConcurrentWorkers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "Mutation",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	rawArgs := field.ArgumentMap(ec.Variables)
+	args, err := ec.field_Mutation_setScannerConcurrentWorkers_args(ctx, rawArgs)
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	fc.Args = args
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Mutation().SetScannerConcurrentWorkers(rctx, args["workers"].(int))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Notification_key(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -5503,6 +5586,60 @@ func (ec *executionContext) _SiteInfo_periodicScanInterval(ctx context.Context,
 	return ec.marshalNInt2int(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _SiteInfo_concurrentWorkers(ctx context.Context, field graphql.CollectedField, obj *models.SiteInfo) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "SiteInfo",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		directive0 := func(rctx context.Context) (interface{}, error) {
+			ctx = rctx // use context from middleware stack in children
+			return obj.ConcurrentWorkers, nil
+		}
+		directive1 := func(ctx context.Context) (interface{}, error) {
+			if ec.directives.IsAdmin == nil {
+				return nil, errors.New("directive isAdmin is not implemented")
+			}
+			return ec.directives.IsAdmin(ctx, obj, directive0)
+		}
+
+		tmp, err := directive1(rctx)
+		if err != nil {
+			return nil, err
+		}
+		if tmp == nil {
+			return nil, nil
+		}
+		if data, ok := tmp.(int); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be int`, tmp)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Subscription_notification(ctx context.Context, field graphql.CollectedField) (ret func() graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -7626,6 +7763,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
 			if out.Values[i] == graphql.Null {
 				invalids++
 			}
+		case "setScannerConcurrentWorkers":
+			out.Values[i] = ec._Mutation_setScannerConcurrentWorkers(ctx, field)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -8041,6 +8183,11 @@ func (ec *executionContext) _SiteInfo(ctx context.Context, sel ast.SelectionSet,
 			if out.Values[i] == graphql.Null {
 				invalids++
 			}
+		case "concurrentWorkers":
+			out.Values[i] = ec._SiteInfo_concurrentWorkers(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go
index ca31bbb..3413a2e 100644
--- a/api/graphql/models/generated.go
+++ b/api/graphql/models/generated.go
@@ -56,6 +56,8 @@ type SiteInfo struct {
 	InitialSetup bool `json:"initialSetup"`
 	// How often automatic scans should be initiated in seconds
 	PeriodicScanInterval int `json:"periodicScanInterval"`
+	// How many max concurrent scanner jobs that should run at once
+	ConcurrentWorkers int `json:"concurrentWorkers"`
 }
 
 type MediaType string
diff --git a/api/graphql/models/site_info.go b/api/graphql/models/site_info.go
index ea362bd..4bec8fd 100644
--- a/api/graphql/models/site_info.go
+++ b/api/graphql/models/site_info.go
@@ -20,7 +20,8 @@ func GetSiteInfo(db *sql.DB) (*SiteInfo, error) {
 	}
 
 	var initialSetup bool
-	var periodicScanInterval int = 0
+	var periodicScanInterval int
+	var concurrentWorkers int
 
 	if !rows.Next() {
 		// Entry does not exist
@@ -29,7 +30,7 @@ func GetSiteInfo(db *sql.DB) (*SiteInfo, error) {
 		}
 		initialSetup = true
 	} else {
-		if err := rows.Scan(&initialSetup, &periodicScanInterval); err != nil {
+		if err := rows.Scan(&initialSetup, &periodicScanInterval, &concurrentWorkers); err != nil {
 			return nil, err
 		}
 	}
@@ -37,5 +38,6 @@ func GetSiteInfo(db *sql.DB) (*SiteInfo, error) {
 	return &SiteInfo{
 		InitialSetup:         initialSetup,
 		PeriodicScanInterval: periodicScanInterval,
+		ConcurrentWorkers:    concurrentWorkers,
 	}, nil
 }
diff --git a/api/graphql/resolvers/scanner.go b/api/graphql/resolvers/scanner.go
index 4d8bc3d..1f16f6b 100644
--- a/api/graphql/resolvers/scanner.go
+++ b/api/graphql/resolvers/scanner.go
@@ -62,3 +62,25 @@ func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval
 
 	return dbInterval, nil
 }
+
+func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
+	if workers < 0 {
+		return 0, errors.New("concurrent workers must be positive")
+	}
+
+	_, err := r.Database.Exec("UPDATE site_info SET concurrent_workers = ?", workers)
+	if err != nil {
+		return 0, err
+	}
+
+	var dbWorkers int
+
+	row := r.Database.QueryRow("SELECT concurrent_workers FROM site_info")
+	if err = row.Scan(&dbWorkers); err != nil {
+		return 0, err
+	}
+
+	scanner.ChangeScannerConcurrentWorkers(dbWorkers)
+
+	return dbWorkers, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index d3932e6..cd97f61 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -98,6 +98,9 @@ type Mutation {
   a value of 0 will disable periodic scans
   """
   setPeriodicScanInterval(interval: Int!): Int!
+
+  "Set max number of concurrent scanner jobs running at once"
+  setScannerConcurrentWorkers(workers: Int!): Int!
 }
 
 type Subscription {
@@ -158,6 +161,8 @@ type SiteInfo {
   initialSetup: Boolean!
   "How often automatic scans should be initiated in seconds"
   periodicScanInterval: Int! @isAdmin
+  "How many max concurrent scanner jobs that should run at once"
+  concurrentWorkers: Int! @isAdmin
 }
 
 type User {
diff --git a/api/scanner/queue.go b/api/scanner/queue.go
index 8ba4a2b..03bb10a 100644
--- a/api/scanner/queue.go
+++ b/api/scanner/queue.go
@@ -37,16 +37,33 @@ type ScannerQueue struct {
 
 var global_scanner_queue ScannerQueue
 
-func InitializeScannerQueue(db *sql.DB) {
+func InitializeScannerQueue(db *sql.DB) error {
+
+	var concurrentWorkers int
+	row := db.QueryRow("SELECT concurrent_workers FROM site_info")
+	if err := row.Scan(&concurrentWorkers); err != nil {
+		return errors.Wrap(err, "get current workers from database")
+	}
+
 	global_scanner_queue = ScannerQueue{
 		idle_chan:   make(chan bool, 1),
 		in_progress: make([]ScannerJob, 0),
 		up_next:     make([]ScannerJob, 0),
 		db:          db,
-		settings:    ScannerQueueSettings{max_concurrent_tasks: 3},
+		settings:    ScannerQueueSettings{max_concurrent_tasks: concurrentWorkers},
 	}
 
 	go global_scanner_queue.startBackgroundWorker()
+
+	return nil
+}
+
+func ChangeScannerConcurrentWorkers(newMaxWorkers int) {
+	global_scanner_queue.mutex.Lock()
+	defer global_scanner_queue.mutex.Unlock()
+
+	log.Printf("Scanner max concurrent workers changed to: %d", newMaxWorkers)
+	global_scanner_queue.settings.max_concurrent_tasks = newMaxWorkers
 }
 
 func (queue *ScannerQueue) startBackgroundWorker() {
diff --git a/api/server.go b/api/server.go
index 7155c87..f9b1ff7 100644
--- a/api/server.go
+++ b/api/server.go
@@ -41,8 +41,13 @@ func main() {
 		log.Panicf("Could not migrate database: %s\n", err)
 	}
 
-	scanner.InitializeScannerQueue(db)
-	scanner.InitializePeriodicScanner(db)
+	if err := scanner.InitializeScannerQueue(db); err != nil {
+		log.Panicf("Could not initialize scanner queue: %s\n", err)
+	}
+
+	if err := scanner.InitializePeriodicScanner(db); err != nil {
+		log.Panicf("Could not initialize periodic scanner: %s", err)
+	}
 
 	rootRouter := mux.NewRouter()
 
diff --git a/ui/src/Pages/SettingsPage/PeriodicScanner.js b/ui/src/Pages/SettingsPage/PeriodicScanner.js
new file mode 100644
index 0000000..14e8bf4
--- /dev/null
+++ b/ui/src/Pages/SettingsPage/PeriodicScanner.js
@@ -0,0 +1,237 @@
+import React, { useRef, useState } from 'react'
+
+import {
+  Button,
+  Checkbox,
+  Dropdown,
+  Icon,
+  Input,
+  Loader,
+} from 'semantic-ui-react'
+import { useMutation, useQuery } from 'react-apollo'
+import gql from 'graphql-tag'
+import styled from 'styled-components'
+import {
+  SectionTitle,
+  InputLabelTitle,
+  InputLabelDescription,
+} from './SettingsPage'
+
+const SCAN_INTERVAL_QUERY = gql`
+  query scanIntervalQuery {
+    siteInfo {
+      periodicScanInterval
+    }
+  }
+`
+
+const SCAN_INTERVAL_MUTATION = gql`
+  mutation changeScanIntervalMutation($interval: Int!) {
+    setPeriodicScanInterval(interval: $interval)
+  }
+`
+
+const timeUnits = [
+  {
+    value: 'second',
+    multiplier: 1,
+  },
+  {
+    value: 'minute',
+    multiplier: 60,
+  },
+  {
+    value: 'hour',
+    multiplier: 60 * 60,
+  },
+  {
+    value: 'day',
+    multiplier: 60 * 60 * 24,
+  },
+  {
+    value: 'month',
+    multiplier: 60 * 60 * 24 * 30,
+  },
+]
+
+const convertToSeconds = ({ value, unit }) => {
+  return parseInt(value * timeUnits.find(x => x.value == unit).multiplier)
+}
+
+const convertToAppropriateUnit = ({ value, unit }) => {
+  if (value == 0) {
+    return {
+      unit: 'second',
+      value: 0,
+    }
+  }
+
+  const seconds = convertToSeconds({ value, unit })
+
+  let resultingUnit = timeUnits.first
+  for (const unit of timeUnits) {
+    if (
+      seconds / unit.multiplier >= 1 &&
+      (seconds / unit.multiplier) % 1 == 0
+    ) {
+      resultingUnit = unit
+    } else {
+      break
+    }
+  }
+
+  return {
+    unit: resultingUnit.value,
+    value: seconds / resultingUnit.multiplier,
+  }
+}
+
+const PeriodicScanner = () => {
+  const [enablePeriodicScanner, setEnablePeriodicScanner] = useState(false)
+  const [scanInterval, setScanInterval] = useState({
+    value: 4,
+    unit: 'minute',
+  })
+
+  const scanIntervalServerValue = useRef(null)
+
+  const scanIntervalQuery = useQuery(SCAN_INTERVAL_QUERY, {
+    onCompleted(data) {
+      const queryScanInterval = data.siteInfo.periodicScanInterval
+
+      if (queryScanInterval == 0) {
+        setScanInterval({
+          unit: 'second',
+          value: '',
+        })
+      } else {
+        setScanInterval(
+          convertToAppropriateUnit({
+            unit: 'second',
+            value: queryScanInterval,
+          })
+        )
+      }
+
+      setEnablePeriodicScanner(queryScanInterval > 0)
+    },
+  })
+
+  const [
+    setScanIntervalMutation,
+    { loading: scanIntervalMutationLoading },
+  ] = useMutation(SCAN_INTERVAL_MUTATION)
+
+  const onScanIntervalCheckboxChange = checked => {
+    setEnablePeriodicScanner(checked)
+
+    onScanIntervalUpdate(checked ? scanInterval : { value: 0, unit: 'second' })
+  }
+
+  const onScanIntervalUpdate = scanInterval => {
+    const seconds = convertToSeconds(scanInterval)
+
+    if (scanIntervalServerValue.current != seconds) {
+      setScanIntervalMutation({
+        variables: {
+          interval: convertToSeconds(scanInterval),
+        },
+      })
+      scanIntervalServerValue.current = seconds
+    }
+  }
+
+  const scanIntervalUnits = [
+    {
+      key: 'second',
+      text: 'Seconds',
+      value: 'second',
+    },
+    {
+      key: 'minute',
+      text: 'Minutes',
+      value: 'minute',
+    },
+    {
+      key: 'hour',
+      text: 'Hours',
+      value: 'hour',
+    },
+    {
+      key: 'day',
+      text: 'Days',
+      value: 'day',
+    },
+    {
+      key: 'month',
+      text: 'Months',
+      value: 'month',
+    },
+  ]
+
+  return (
+    <>
+      <h3>Periodic scanner</h3>
+
+      <div style={{ margin: '12px 0' }}>
+        <Checkbox
+          label="Enable periodic scanner"
+          disabled={scanIntervalQuery.loading}
+          checked={enablePeriodicScanner}
+          onChange={(_, { checked }) => onScanIntervalCheckboxChange(checked)}
+        />
+      </div>
+
+      {enablePeriodicScanner && (
+        <>
+          <label htmlFor="periodic_scan_field">
+            <InputLabelTitle>Periodic scan interval</InputLabelTitle>
+            <InputLabelDescription>
+              How often the scanner should perform automatic scans of all users
+            </InputLabelDescription>
+          </label>
+          <Input
+            label={
+              <Dropdown
+                onChange={(_, { value }) => {
+                  const newScanInterval = {
+                    ...scanInterval,
+                    unit: value,
+                  }
+
+                  setScanInterval(newScanInterval)
+                  onScanIntervalUpdate(newScanInterval)
+                }}
+                value={scanInterval.unit}
+                options={scanIntervalUnits}
+              />
+            }
+            onBlur={() => onScanIntervalUpdate(scanInterval)}
+            onKeyDown={({ key }) =>
+              key == 'Enter' && onScanIntervalUpdate(scanInterval)
+            }
+            loading={scanIntervalQuery.loading}
+            labelPosition="right"
+            style={{ maxWidth: 300 }}
+            id="periodic_scan_field"
+            value={scanInterval.value}
+            onChange={(_, { value }) => {
+              setScanInterval(x => ({
+                ...x,
+                value,
+              }))
+            }}
+          />
+        </>
+      )}
+      <Loader
+        active={scanIntervalQuery.loading || scanIntervalMutationLoading}
+        inline
+        size="small"
+        style={{ marginLeft: 16 }}
+      />
+    </>
+  )
+}
+
+export default PeriodicScanner
diff --git a/ui/src/Pages/SettingsPage/ScannerConcurrentWorkers.js b/ui/src/Pages/SettingsPage/ScannerConcurrentWorkers.js
new file mode 100644
index 0000000..82a4c06
--- /dev/null
+++ b/ui/src/Pages/SettingsPage/ScannerConcurrentWorkers.js
@@ -0,0 +1,80 @@
+import gql from 'graphql-tag'
+import React, { useRef, useState } from 'react'
+import { useQuery, useMutation } from 'react-apollo'
+import { Input, Loader } from 'semantic-ui-react'
+import { InputLabelTitle, InputLabelDescription } from './SettingsPage'
+
+const CONCURRENT_WORKERS_QUERY = gql`
+  query concurrentWorkersQuery {
+    siteInfo {
+      concurrentWorkers
+    }
+  }
+`
+
+const SET_CONCURRENT_WORKERS_MUTATION = gql`
+  mutation setConcurrentWorkers($workers: Int!) {
+    setScannerConcurrentWorkers(workers: $workers)
+  }
+`
+
+const ScannerConcurrentWorkers = () => {
+  const workerAmountQuery = useQuery(CONCURRENT_WORKERS_QUERY, {
+    onCompleted(data) {
+      setWorkerAmount(data.siteInfo.concurrentWorkers)
+      workerAmountServerValue.current = data.siteInfo.concurrentWorkers
+    },
+  })
+
+  const [setWorkersMutation, workersMutationData] = useMutation(
+    SET_CONCURRENT_WORKERS_MUTATION
+  )
+
+  const workerAmountServerValue = useRef(null)
+  const [workerAmount, setWorkerAmount] = useState('')
+
+  const updateWorkerAmount = workerAmount => {
+    if (workerAmountServerValue.current != workerAmount) {
+      workerAmountServerValue.current = workerAmount
+      setWorkersMutation({
+        variables: {
+          workers: workerAmount,
+        },
+      })
+    }
+  }
+
+  return (
+    <div style={{ marginTop: 32 }}>
+      <label htmlFor="scanner_concurrent_workers_field">
+        <InputLabelTitle>Scanner Concurrent Workers</InputLabelTitle>
+        <InputLabelDescription>
+          The maximum amount of scanner jobs that is allowed to run at once
+        </InputLabelDescription>
+      </label>
+      <Input
+        disabled={workerAmountQuery.loading || workersMutationData.loading}
+        type="number"
+        min="1"
+        max="24"
+        id="scanner_concurrent_workers_field"
+        value={workerAmount}
+        onChange={(_, { value }) => {
+          setWorkerAmount(value)
+        }}
+        onBlur={() => updateWorkerAmount(workerAmount)}
+        onKeyDown={({ key }) =>
+          key == 'Enter' && updateWorkerAmount(workerAmount)
+        }
+      />
+      <Loader
+        active={workerAmountQuery.loading || workersMutationData.loading}
+        inline
+        size="small"
+        style={{ marginLeft: 16 }}
+      />
+    </div>
+  )
+}
+
+export default ScannerConcurrentWorkers
diff --git a/ui/src/Pages/SettingsPage/ScannerSection.js b/ui/src/Pages/SettingsPage/ScannerSection.js
index 5f1be8c..7f720b3 100644
--- a/ui/src/Pages/SettingsPage/ScannerSection.js
+++ b/ui/src/Pages/SettingsPage/ScannerSection.js
@@ -1,17 +1,10 @@
-import React, { useRef, useState } from 'react'
-
-import {
-  Button,
-  Checkbox,
-  Dropdown,
-  Icon,
-  Input,
-  Loader,
-} from 'semantic-ui-react'
-import { useMutation, useQuery } from 'react-apollo'
 import gql from 'graphql-tag'
-import styled from 'styled-components'
-import { SectionTitle } from './SettingsPage'
+import React from 'react'
+import { useMutation } from 'react-apollo'
+import { Button, Icon } from 'semantic-ui-react'
+import PeriodicScanner from './PeriodicScanner'
+import ScannerConcurrentWorkers from './ScannerConcurrentWorkers'
+import { SectionTitle, InputLabelDescription } from './SettingsPage'
 
 const SCAN_MUTATION = gql`
   mutation scanAllMutation {
@@ -22,174 +15,15 @@ const SCAN_MUTATION = gql`
   }
 `
 
-const SCAN_INTERVAL_QUERY = gql`
-  query scanIntervalQuery {
-    siteInfo {
-      periodicScanInterval
-    }
-  }
-`
-
-const SCAN_INTERVAL_MUTATION = gql`
-  mutation changeScanIntervalMutation($interval: Int!) {
-    setPeriodicScanInterval(interval: $interval)
-  }
-`
-
-const timeUnits = [
-  {
-    value: 'second',
-    multiplier: 1,
-  },
-  {
-    value: 'minute',
-    multiplier: 60,
-  },
-  {
-    value: 'hour',
-    multiplier: 60 * 60,
-  },
-  {
-    value: 'day',
-    multiplier: 60 * 60 * 24,
-  },
-  {
-    value: 'month',
-    multiplier: 60 * 60 * 24 * 30,
-  },
-]
-
-const convertToSeconds = ({ value, unit }) => {
-  return parseInt(value * timeUnits.find(x => x.value == unit).multiplier)
-}
-
-const convertToAppropriateUnit = ({ value, unit }) => {
-  if (value == 0) {
-    return {
-      unit: 'second',
-      value: 0,
-    }
-  }
-
-  const seconds = convertToSeconds({ value, unit })
-
-  let resultingUnit = timeUnits.first
-  for (const unit of timeUnits) {
-    if (
-      seconds / unit.multiplier >= 1 &&
-      (seconds / unit.multiplier) % 1 == 0
-    ) {
-      resultingUnit = unit
-    } else {
-      break
-    }
-  }
-
-  return {
-    unit: resultingUnit.value,
-    value: seconds / resultingUnit.multiplier,
-  }
-}
-
-const InputLabelTitle = styled.p`
-  font-size: 1.1em;
-  font-weight: 600;
-  margin: 1em 0 0 !important;
-`
-
-const InputLabelDescription = styled.p`
-  font-size: 0.9em;
-  margin: 0 0 0.5em !important;
-`
-
 const ScannerSection = () => {
   const [startScanner, { called }] = useMutation(SCAN_MUTATION)
 
-  const [enablePeriodicScanner, setEnablePeriodicScanner] = useState(false)
-  const [scanInterval, setScanInterval] = useState({
-    value: 4,
-    unit: 'minute',
-  })
-
-  const scanIntervalServerValue = useRef(null)
-
-  const scanIntervalQuery = useQuery(SCAN_INTERVAL_QUERY, {
-    onCompleted(data) {
-      const queryScanInterval = data.siteInfo.periodicScanInterval
-
-      if (queryScanInterval == 0) {
-        setScanInterval({
-          unit: 'second',
-          value: '',
-        })
-      } else {
-        setScanInterval(
-          convertToAppropriateUnit({
-            unit: 'second',
-            value: queryScanInterval,
-          })
-        )
-      }
-
-      setEnablePeriodicScanner(queryScanInterval > 0)
-    },
-  })
-
-  const [
-    setScanIntervalMutation,
-    { loading: scanIntervalMutationLoading },
-  ] = useMutation(SCAN_INTERVAL_MUTATION)
-
-  const onScanIntervalCheckboxChange = checked => {
-    setEnablePeriodicScanner(checked)
-
-    onScanIntervalUpdate(checked ? scanInterval : { value: 0, unit: 'second' })
-  }
-
-  const onScanIntervalUpdate = scanInterval => {
-    const seconds = convertToSeconds(scanInterval)
-
-    if (scanIntervalServerValue.current != seconds) {
-      setScanIntervalMutation({
-        variables: {
-          interval: convertToSeconds(scanInterval),
-        },
-      })
-      scanIntervalServerValue.current = seconds
-    }
-  }
-
-  const scanIntervalUnits = [
-    {
-      key: 'second',
-      text: 'Seconds',
-      value: 'second',
-    },
-    {
-      key: 'minute',
-      text: 'Minutes',
-      value: 'minute',
-    },
-    {
-      key: 'hour',
-      text: 'Hours',
-      value: 'hour',
-    },
-    {
-      key: 'day',
-      text: 'Days',
-      value: 'day',
-    },
-    {
-      key: 'month',
-      text: 'Months',
-      value: 'month',
-    },
-  ]
-
   return (
     <div>
       <SectionTitle nospace>Scanner</SectionTitle>
+      <InputLabelDescription>
+        Will scan all users for new or updated media
+      </InputLabelDescription>
       <Button
         icon
         labelPosition="left"
@@ -199,68 +33,10 @@ const ScannerSection = () => {
         disabled={called}
       >
         <Icon name="sync" />
-        Scan All
+        Scan all users
       </Button>
-
-      <h3>Periodic scanner</h3>
-
-      <div style={{ margin: '12px 0' }}>
-        <Checkbox
-          label="Enable periodic scanner"
-          disabled={scanIntervalQuery.loading}
-          checked={enablePeriodicScanner}
-          onChange={(_, { checked }) => onScanIntervalCheckboxChange(checked)}
-        />
-      </div>
-
-      {enablePeriodicScanner && (
-        <>
-          <label htmlFor="periodic_scan_field">
-            <InputLabelTitle>Periodic scan interval</InputLabelTitle>
-            <InputLabelDescription>
-              How often the scanner should perform automatic scans of all users
-            </InputLabelDescription>
-          </label>
-          <Input
-            label={
-              <Dropdown
-                onChange={(_, { value }) => {
-                  const newScanInterval = {
-                    ...scanInterval,
-                    unit: value,
-                  }
-
-                  setScanInterval(newScanInterval)
-                  onScanIntervalUpdate(newScanInterval)
-                }}
-                value={scanInterval.unit}
-                options={scanIntervalUnits}
-              />
-            }
-            onBlur={() => onScanIntervalUpdate(scanInterval)}
-            onKeyDown={({ key }) =>
-              key == 'Enter' && onScanIntervalUpdate(scanInterval)
-            }
-            loading={scanIntervalQuery.loading}
-            labelPosition="right"
-            style={{ maxWidth: 300 }}
-            id="periodic_scan_field"
-            value={scanInterval.value}
-            onChange={(_, { value }) => {
-              setScanInterval(x => ({
-                ...x,
-                value,
-              }))
-            }}
-          />
-        </>
-      )}
-      <Loader
-        active={scanIntervalQuery.loading || scanIntervalMutationLoading}
-        inline
-        size="small"
-        style={{ marginLeft: 16 }}
-      />
+      <PeriodicScanner />
+      <ScannerConcurrentWorkers />
     </div>
   )
 }
diff --git a/ui/src/Pages/SettingsPage/SettingsPage.js b/ui/src/Pages/SettingsPage/SettingsPage.js
index 11b9394..abd73e0 100644
--- a/ui/src/Pages/SettingsPage/SettingsPage.js
+++ b/ui/src/Pages/SettingsPage/SettingsPage.js
@@ -12,6 +12,17 @@ export const SectionTitle = styled.h2`
   border-bottom: 1px solid #ddd;
 `
 
+export const InputLabelTitle = styled.p`
+  font-size: 1.1em;
+  font-weight: 600;
+  margin: 1em 0 0 !important;
+`
+
+export const InputLabelDescription = styled.p`
+  font-size: 0.9em;
+  margin: 0 0 0.5em !important;
+`
+
 const SettingsPage = () => {
   return (
     <Layout>
diff --git a/ui/src/Pages/SettingsPage/UserRow.js b/ui/src/Pages/SettingsPage/UserRow.js
index c2a3a43..d580200 100644
--- a/ui/src/Pages/SettingsPage/UserRow.js
+++ b/ui/src/Pages/SettingsPage/UserRow.js
@@ -145,6 +145,7 @@ const UserRow = ({ user, refetchUsers }) => {
           <Table.Row>
             <Table.Cell>
               <Input
+                style={{ width: '100%' }}
                 placeholder={user.username}
                 value={state.username}
                 onChange={e => updateInput(e, 'username')}
@@ -152,6 +153,7 @@ const UserRow = ({ user, refetchUsers }) => {
             </Table.Cell>
             <Table.Cell>
               <Input
+                style={{ width: '100%' }}
                 placeholder={user.rootPath}
                 value={state.rootPath}
                 onChange={e => updateInput(e, 'rootPath')}
