diff --git a/.env b/.env index db7913a3..b88c588c 100644 --- a/.env +++ b/.env @@ -19,4 +19,5 @@ LOG_LEVEL_FILE=info LOG_LEVEL_CONSOLE=warn FRACTAL_API_V1_MODE=include +FRACTAL_RUNNER_BACKEND=local #WARNING_BANNER_PATH=/path/to/banner.txt diff --git a/.env.development b/.env.development index af4b7a02..b9e10841 100644 --- a/.env.development +++ b/.env.development @@ -18,4 +18,5 @@ LOG_LEVEL_FILE=debug LOG_LEVEL_CONSOLE=info FRACTAL_API_V1_MODE=include +FRACTAL_RUNNER_BACKEND=local #WARNING_BANNER_PATH=/path/to/banner.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1e9f0e..97f4ffd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ # Unreleased +* Added "My Settings" page and updated admin user editor (\#564); * Fixed duplicated entries in owner dropdown (\#562); * Added "View plate" button on dataset page (\#562); * Improved stability of end to end tests (\#560); diff --git a/__tests__/v2/RunWorkflowModal.test.js b/__tests__/v2/RunWorkflowModal.test.js index 59f4dc8f..7fe483c6 100644 --- a/__tests__/v2/RunWorkflowModal.test.js +++ b/__tests__/v2/RunWorkflowModal.test.js @@ -1,19 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent, render } from '@testing-library/svelte'; -import { readable } from 'svelte/store'; // Mocking fetch global.fetch = vi.fn(); -// Mocking the page store -vi.mock('$app/stores', () => { - return { - page: readable({ - data: { - userInfo: { slurm_accounts: [] } - } - }) - }; +fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => new Promise((resolve) => resolve({ slurm_accounts: [] })) }); // Mocking bootstrap.Modal diff --git a/__tests__/v2/UserEditor.test.js b/__tests__/v2/UserEditor.test.js new file mode 100644 index 00000000..d3ee6ef8 --- /dev/null +++ b/__tests__/v2/UserEditor.test.js @@ -0,0 +1,250 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { readable } from 'svelte/store'; + +// Mocking fetch +global.fetch = vi.fn(); + +// Mocking the page store +vi.mock('$app/stores', () => { + return { + page: readable({ + data: { + userInfo: { + id: 2 + } + } + }) + }; +}); + +// The component to be tested must be imported after the mock setup +import UserEditor from '../../src/lib/components/v2/admin/UserEditor.svelte'; + +describe('UserEditor', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + const selectedUser = { + id: 1, + group_ids: [] + }; + + const initialSettings = { + id: 1, + slurm_accounts: [], + slurm_user: null, + cache_dir: null, + ssh_host: null, + ssh_username: null, + ssh_private_key_path: null, + ssh_tasks_dir: null, + ssh_jobs_dir: null + }; + + it('Update settings with slurm runner backend - success', async () => { + const user = userEvent.setup(); + + render(UserEditor, { + props: { + runnerBackend: 'slurm', + user: selectedUser, + settings: { ...initialSettings }, + save: () => {} + } + }); + + const mockRequest = fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + new Promise((resolve) => + resolve({ + ...initialSettings, + slurm_user: 'user', + cache_dir: '/path/to/cache/dir' + }) + ) + }); + + await user.type(screen.getByRole('textbox', { name: 'SLURM user' }), 'user'); + await user.type(screen.getByRole('textbox', { name: 'Cache dir' }), '/path/to/cache/dir'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + await screen.findByText('Settings successfully updated'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({ + slurm_user: 'user', + cache_dir: '/path/to/cache/dir', + slurm_accounts: [] + }) + }) + ); + }); + + it('Update settings with slurm runner backend - validation error', async () => { + const user = userEvent.setup(); + + render(UserEditor, { + props: { + runnerBackend: 'slurm', + user: selectedUser, + settings: { ...initialSettings }, + save: () => {} + } + }); + + const mockRequest = fetch.mockResolvedValue({ + ok: false, + status: 422, + json: () => + new Promise((resolve) => + resolve({ + detail: [ + { + loc: ['body', 'cache_dir'], + msg: 'mocked_error', + type: 'value_error' + } + ] + }) + ) + }); + + await user.type(screen.getByRole('textbox', { name: 'Cache dir' }), 'xxx'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + await screen.findByText('mocked_error'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({ + cache_dir: 'xxx', + slurm_accounts: [] + }) + }) + ); + }); + + it('Update settings with slurm_ssh runner backend - success', async () => { + const user = userEvent.setup(); + + render(UserEditor, { + props: { + runnerBackend: 'slurm_ssh', + user: selectedUser, + settings: { ...initialSettings }, + save: () => {} + } + }); + + const mockRequest = fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + new Promise((resolve) => + resolve({ + ...initialSettings, + ssh_host: 'localhost', + ssh_username: 'username', + ssh_private_key_path: '/path/to/private/key', + ssh_tasks_dir: '/path/to/tasks/dir', + ssh_jobs_dir: '/path/to/jobs/dir' + }) + ) + }); + + await user.type(screen.getByRole('textbox', { name: 'SSH host' }), 'localhost'); + await user.type(screen.getByRole('textbox', { name: 'SSH username' }), 'username'); + await user.type(screen.getByRole('textbox', { name: 'SSH Private Key Path' }), 'xxx'); + await user.type(screen.getByRole('textbox', { name: 'SSH Tasks Dir' }), 'yyy'); + await user.type(screen.getByRole('textbox', { name: 'SSH Jobs Dir' }), 'zzz'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + await screen.findByText('Settings successfully updated'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({ + ssh_host: 'localhost', + ssh_username: 'username', + ssh_private_key_path: 'xxx', + ssh_tasks_dir: 'yyy', + ssh_jobs_dir: 'zzz', + slurm_accounts: [] + }) + }) + ); + }); + + it('Update settings with slurm_ssh runner backend - validation error', async () => { + const user = userEvent.setup(); + + render(UserEditor, { + props: { + runnerBackend: 'slurm_ssh', + user: selectedUser, + settings: { ...initialSettings }, + save: () => {} + } + }); + + const mockRequest = fetch.mockResolvedValue({ + ok: false, + status: 422, + json: () => + new Promise((resolve) => + resolve({ + detail: [ + { + loc: ['body', 'ssh_private_key_path'], + msg: 'mock_error_ssh_private_key_path', + type: 'value_error' + }, + { + loc: ['body', 'ssh_tasks_dir'], + msg: 'mock_error_ssh_tasks_dir', + type: 'value_error' + }, + { + loc: ['body', 'ssh_jobs_dir'], + msg: 'mock_error_ssh_jobs_dir', + type: 'value_error' + } + ] + }) + ) + }); + + await user.type(screen.getByRole('textbox', { name: 'SSH host' }), 'localhost'); + await user.type(screen.getByRole('textbox', { name: 'SSH username' }), 'username'); + await user.type( + screen.getByRole('textbox', { name: 'SSH Private Key Path' }), + '/path/to/private/key' + ); + await user.type(screen.getByRole('textbox', { name: 'SSH Tasks Dir' }), '/path/to/tasks/dir'); + await user.type(screen.getByRole('textbox', { name: 'SSH Jobs Dir' }), '/path/to/jobs/dir'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + await screen.findByText('mock_error_ssh_private_key_path'); + await screen.findByText('mock_error_ssh_tasks_dir'); + await screen.findByText('mock_error_ssh_jobs_dir'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({ + ssh_host: 'localhost', + ssh_username: 'username', + ssh_private_key_path: '/path/to/private/key', + ssh_tasks_dir: '/path/to/tasks/dir', + ssh_jobs_dir: '/path/to/jobs/dir', + slurm_accounts: [] + }) + }) + ); + }); +}); diff --git a/__tests__/v2/workflow_page.test.js b/__tests__/v2/workflow_page.test.js index 037f560e..bdf69be6 100644 --- a/__tests__/v2/workflow_page.test.js +++ b/__tests__/v2/workflow_page.test.js @@ -63,6 +63,8 @@ describe('Workflow page', () => { log: 'Exception error occurred while creating job folder and subfolders.\nOriginal error: test' } ]; + case '/api/auth/current-user/settings': + return { slurm_accounts: [] }; default: throw Error(`Unexpected API call: ${url}`); } diff --git a/docs/environment-variables.md b/docs/environment-variables.md index b8c1e164..10083475 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -17,6 +17,7 @@ The following environment variables can be used to configure fractal-web. * `LOG_LEVEL_FILE`: the log level of logs that will be written to the file; the default value is `info`; * `LOG_LEVEL_CONSOLE`: the log level of logs that will be written to the console; the default value is `warn`; * `FRACTAL_API_V1_MODE`: include/exclude V1 pages and version switcher; supported values are: `include`, `include_read_only`, `exclude`; the default value is `include`; +* `FRACTAL_RUNNER_BACKEND`: specifies which runner backend is used; supported values are: `local`, `local_experimental`, `slurm`, `slurm_ssh`; the default value is `local`; * `PUBLIC_FRACTAL_VIZARR_VIEWER_URL`: URL to [fractal-vizarr-viewer](https://github.com/fractal-analytics-platform/fractal-vizarr-viewer) service (e.g. http://localhost:3000/vizarr for testing); * `WARNING_BANNER_PATH`: specifies the path to a text file containing the warning banner message displayed on the site; the banner is used to inform users about important issues, such as external resources downtime or maintenance alerts; if the variable is empty or unset no banner is displayed. diff --git a/docs/quickstart.md b/docs/quickstart.md index 32c7ed7e..90a82b3b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -56,6 +56,7 @@ export LOG_FILE=fractal-web.log # export LOG_LEVEL_CONSOLE=warn export FRACTAL_API_V1_MODE=include +export FRACTAL_RUNNER_BACKEND=local #export PUBLIC_FRACTAL_VIZARR_VIEWER_URL= #export WARNING_BANNER_PATH= diff --git a/playwright.config.js b/playwright.config.js index 9a0daf32..1025702a 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -107,7 +107,7 @@ export default defineConfig({ webServer: [ { - command: './tests/start-test-server.sh 2.5.0a1', + command: './tests/start-test-server.sh 2.6.0a1', port: 8000, waitForPort: true, stdout: 'pipe', @@ -123,7 +123,7 @@ export default defineConfig({ }, { command: - 'npm run build && LOG_LEVEL_CONSOLE=debug ORIGIN=http://localhost:5173 PORT=5173 node build', + 'npm run build && LOG_LEVEL_CONSOLE=debug ORIGIN=http://localhost:5173 PORT=5173 FRACTAL_RUNNER_BACKEND=slurm node build', port: 5173, stdout: 'pipe', reuseExistingServer: !process.env.CI diff --git a/src/lib/components/v2/admin/UserEditor.svelte b/src/lib/components/v2/admin/UserEditor.svelte index ff2e8bee..e453abef 100644 --- a/src/lib/components/v2/admin/UserEditor.svelte +++ b/src/lib/components/v2/admin/UserEditor.svelte @@ -7,13 +7,19 @@ import Modal from '$lib/components/common/Modal.svelte'; import { sortGroupByNameComparator } from '$lib/common/user_utilities'; import SlimSelect from 'slim-select'; + import { stripNullAndEmptyObjectsAndArrays } from 'fractal-jschema'; + import StandardDismissableAlert from '$lib/components/common/StandardDismissableAlert.svelte'; /** @type {import('$lib/types').User & {group_ids: number[]}} */ export let user; /** @type {Array} */ - export let groups; + export let groups = []; + /** @type {import('$lib/types').UserSettings|null} */ + export let settings = null; /** @type {(user: import('$lib/types').User) => Promise} */ export let save; + /** @type {string} */ + export let runnerBackend; /** @type {import('$lib/components/common/StandardErrorAlert.svelte').default|undefined} */ let errorAlert = undefined; @@ -37,51 +43,65 @@ let password = ''; let confirmPassword = ''; - let saving = false; - let formSubmitted = false; + let savingUser = false; + let userFormSubmitted = false; - const formErrorHandler = new FormErrorHandler('genericError', [ + let savingSettings = false; + let settingsFormSubmitted = false; + + let userUpdatedMessage = ''; + let settingsUpdatedMessage = ''; + + const userFormErrorHandler = new FormErrorHandler('genericUserError', [ 'email', 'username', - 'slurm_user', + 'password' + ]); + + const settingsFormErrorHandler = new FormErrorHandler('genericSettingsError', [ 'cache_dir', - 'password', - 'slurm_accounts' + 'slurm_accounts', + 'slurm_user', + 'ssh_host', + 'ssh_username', + 'ssh_private_key_path', + 'ssh_tasks_dir', + 'ssh_jobs_dir' ]); - const validationErrors = formErrorHandler.getValidationErrorStore(); + const userValidationErrors = userFormErrorHandler.getValidationErrorStore(); + const settingsValidationErrors = settingsFormErrorHandler.getValidationErrorStore(); /** @type {Modal} */ let confirmSuperuserChange; let initialSuperuserValue = false; - /** - * @param {SubmitEvent} event - */ - async function handleSave(event) { - saving = true; + async function handleSaveUser() { + savingUser = true; + userUpdatedMessage = ''; try { - formSubmitted = true; - formErrorHandler.clearErrors(); - validateFields(); - if (Object.keys($validationErrors).length > 0) { + userFormSubmitted = true; + userFormErrorHandler.clearErrors(); + validateUserFields(); + if (Object.keys($userValidationErrors).length > 0) { return; } if (user.is_superuser === initialSuperuserValue) { - await confirmSave(); + await confirmSaveUser(); } else { - saving = false; - event.preventDefault(); + savingUser = false; confirmSuperuserChange.show(); } } finally { - saving = false; + savingUser = false; } } - async function confirmSave() { - saving = true; + async function confirmSaveUser() { + savingUser = true; + confirmSuperuserChange.hide(); try { + let existing = !!user.id; const groupsSuccess = await addGroups(); if (!groupsSuccess) { return; @@ -92,7 +112,7 @@ const userData = removeNullValues(nullifyEmptyStrings(user)); const response = await save(userData); if (!response.ok) { - await formErrorHandler.handleErrorResponse(response); + await userFormErrorHandler.handleErrorResponse(response); return; } const result = await response.json(); @@ -100,15 +120,20 @@ // If the user modifies their own account the userInfo cached in the store has to be reloaded await invalidateAll(); } - await goto('/v2/admin/users'); + if (existing) { + user = { ...result }; + userUpdatedMessage = 'User successfully updated'; + } else { + await goto(`/v2/admin/users/${result.id}/edit`); + } } finally { - saving = false; + savingUser = false; } } - function validateFields() { + function validateUserFields() { if (!user.email) { - formErrorHandler.addValidationError('email', 'Field is required'); + userFormErrorHandler.addValidationError('email', 'Field is required'); } validatePassword(); } @@ -118,22 +143,28 @@ return; } if (!password) { - formErrorHandler.addValidationError('password', 'Field is required'); + userFormErrorHandler.addValidationError('password', 'Field is required'); } if (password !== confirmPassword) { - formErrorHandler.addValidationError('confirmPassword', "Passwords don't match"); + userFormErrorHandler.addValidationError('confirmPassword', "Passwords don't match"); } } function addSlurmAccount() { - user.slurm_accounts = [...user.slurm_accounts, '']; + if (!settings) { + return; + } + settings.slurm_accounts = [...settings.slurm_accounts, '']; } /** * @param {number} index */ function removeSlurmAccount(index) { - user.slurm_accounts = user.slurm_accounts.filter((_, i) => i !== index); + if (!settings) { + return; + } + settings.slurm_accounts = settings.slurm_accounts.filter((_, i) => i !== index); } /** @type {Modal} */ @@ -200,10 +231,42 @@ return true; } - errorAlert = displayStandardErrorAlert(new AlertError(result, response.status), 'genericError'); + errorAlert = displayStandardErrorAlert( + new AlertError(result, response.status), + 'genericUserError' + ); return false; } + async function handleSaveSettings() { + savingSettings = true; + settingsUpdatedMessage = ''; + try { + settingsFormSubmitted = true; + settingsFormErrorHandler.clearErrors(); + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const response = await fetch(`/api/auth/users/${user.id}/settings`, { + method: 'PATCH', + credentials: 'include', + headers, + body: JSON.stringify({ + ...stripNullAndEmptyObjectsAndArrays({ ...settings, id: undefined }), + slurm_accounts: settings?.slurm_accounts + }) + }); + if (!response.ok) { + await settingsFormErrorHandler.handleErrorResponse(response); + return; + } + const result = await response.json(); + settings = { ...result }; + settingsUpdatedMessage = 'Settings successfully updated'; + } finally { + savingSettings = false; + } + } + onMount(() => { initialSuperuserValue = user.is_superuser; setGroupsSlimSelect(); @@ -246,10 +309,10 @@ } -
+
-
+
{#if user.id}
{#if user.id && user.id !== $page.data.userInfo.id} @@ -329,10 +392,10 @@ class="form-control" id="password" bind:value={password} - class:is-invalid={formSubmitted && $validationErrors['password']} + class:is-invalid={userFormSubmitted && $userValidationErrors['password']} /> Create a new password for this Fractal user - {$validationErrors['password']} + {$userValidationErrors['password']}
@@ -345,67 +408,9 @@ class="form-control" id="confirmPassword" bind:value={confirmPassword} - class:is-invalid={formSubmitted && $validationErrors['confirmPassword']} - /> - {$validationErrors['confirmPassword']} -
-
-
- -
- -
- The user who will be impersonated by Fractal when running SLURM jobs -
- {$validationErrors['slurm_user']} -
-
-
- -
- {#each user.slurm_accounts as slurmAccount, i} -
- - -
- {/each} - {$validationErrors['slurm_accounts']} - -
- The first account in the list will be used as a default for job execution. -
+ {$userValidationErrors['confirmPassword']}
@@ -419,32 +424,14 @@ type="text" class="form-control" id="username" - class:is-invalid={formSubmitted && $validationErrors['username']} + class:is-invalid={userFormSubmitted && $userValidationErrors['username']} bind:value={user.username} /> Optional property (useful if the user creates their own tasks), not required if the SLURM user is set - {$validationErrors['username']} -
- -
- -
- -
- Absolute path to a user-owned folder that will be used as a cache for job-related files -
- {$validationErrors['cache_dir']} + {$userValidationErrors['username']}
{#if user.id} @@ -477,18 +464,230 @@ + {:else} +
+
+
User settings can be modified after creating the user
+
+
{/if}
-
- + + + {#if settings && runnerBackend !== 'local' && runnerBackend !== 'local_experimental'} +
+ +
+
+

Settings

+
+ {#if runnerBackend === 'slurm'} +
+ +
+ +
+ The user on the local SLURM cluster who will be impersonated by Fractal through + sudo -u +
+ {$settingsValidationErrors['slurm_user']} +
+
+
+ +
+ +
+ Absolute path to a user-owned folder that will be used as a cache for job-related + files +
+ {$settingsValidationErrors['cache_dir']} +
+
+ {:else if runnerBackend === 'slurm_ssh'} +
+ +
+ +
SSH-reachable host where a SLURM client is available
+ {$settingsValidationErrors['ssh_host']} +
+
+
+ +
+ +
+ The user on the remote SLURM cluster who will be impersonated by Fractal through + ssh +
+ {$settingsValidationErrors['ssh_username']} +
+
+
+ +
+ +
+ Path of private SSH key for ssh_username +
+ {$settingsValidationErrors['ssh_private_key_path']} +
+
+
+ +
+ +
+ Task-venvs base folder on ssh_host +
+ {$settingsValidationErrors['ssh_tasks_dir']} +
+
+
+ +
+ +
+ Jobs base folder on ssh_host +
+ {$settingsValidationErrors['ssh_jobs_dir']} +
+
+ {/if} +
+ +
+ {#each settings.slurm_accounts as slurmAccount, i} +
+ + +
+ {/each} + {$settingsValidationErrors['slurm_accounts']} + +
+ The first account in the list will be used as a default for job execution. +
+
+
+
+
+ + +
+
+
+ {/if} - + diff --git a/src/lib/components/v2/workflow/RunWorkflowModal.svelte b/src/lib/components/v2/workflow/RunWorkflowModal.svelte index 481c25de..551d316e 100644 --- a/src/lib/components/v2/workflow/RunWorkflowModal.svelte +++ b/src/lib/components/v2/workflow/RunWorkflowModal.svelte @@ -1,5 +1,4 @@ @@ -364,7 +383,7 @@ bind:value={workerInitControl} />
- {#if $page.data.userInfo.slurm_accounts.length > 0} + {#if slurmAccounts.length > 0}
- {#each $page.data.userInfo.slurm_accounts as account} + {#each slurmAccounts as account} {/each} diff --git a/src/lib/server/api/auth_api.js b/src/lib/server/api/auth_api.js index 83e6bb21..a6249cdc 100644 --- a/src/lib/server/api/auth_api.js +++ b/src/lib/server/api/auth_api.js @@ -48,6 +48,27 @@ export async function getCurrentUser(fetch, groupNames = false) { return await response.json(); } +/** + * Fetches user settings + * @param {typeof fetch} fetch + * @returns {Promise} + */ +export async function getCurrentUserSettings(fetch) { + logger.debug('Retrieving current user settings'); + const url = `${env.FRACTAL_SERVER_HOST}/auth/current-user/settings/`; + const response = await fetch(url, { + method: 'GET', + credentials: 'include' + }); + + if (!response.ok) { + logger.error('Unable to retrieve the current user settings'); + await responseError(response); + } + + return await response.json(); +} + /** * Requests to close a user session on the server * @param {typeof fetch} fetch @@ -98,13 +119,37 @@ export async function listUsers(fetch) { */ export async function getUser(fetch, userId, groupIds = true) { logger.debug('Fetching user [user_id=%d]', userId); - const response = await fetch(`${env.FRACTAL_SERVER_HOST}/auth/users/${userId}/?group_ids=${groupIds}`, { + const response = await fetch( + `${env.FRACTAL_SERVER_HOST}/auth/users/${userId}/?group_ids=${groupIds}`, + { + method: 'GET', + credentials: 'include' + } + ); + + if (!response.ok) { + logger.error('Unable to fetch user [user_id=%d]', userId); + await responseError(response); + } + + return await response.json(); +} + +/** + * Fetches user settings from the server + * @param {typeof fetch} fetch + * @param {number|string} userId + * @returns {Promise} + */ +export async function getUserSettings(fetch, userId) { + logger.debug('Fetching settings for user [user_id=%d]', userId); + const response = await fetch(`${env.FRACTAL_SERVER_HOST}/auth/users/${userId}/settings/`, { method: 'GET', credentials: 'include' }); if (!response.ok) { - logger.error('Unable to fetch user [user_id=%d]', userId); + logger.error('Unable to fetch user settings [user_id=%d]', userId); await responseError(response); } diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 148075db..ba207d52 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -111,10 +111,7 @@ export type User = { is_superuser: boolean is_verified: boolean username: string | null - slurm_user: string | null - cache_dir: string | null password?: string - slurm_accounts: string[] group_names?: string[] group_ids?: number[] oauth_accounts: Array<{ @@ -124,6 +121,19 @@ export type User = { }> } +export type UserSettings = { + slurm_accounts: string[] + // Slurm + slurm_user: string | null + cache_dir: string | null + // Slurm SSH + ssh_host: string | null + ssh_username: string | null + ssh_private_key_path: string | null + ssh_tasks_dir: string | null + ssh_jobs_dir: string | null +} + export type Group = { id: number name: string diff --git a/src/routes/+layout.server.js b/src/routes/+layout.server.js index bf89bab8..620a6a06 100644 --- a/src/routes/+layout.server.js +++ b/src/routes/+layout.server.js @@ -22,7 +22,8 @@ export async function load({ locals, request, url }) { return { ...pageInfo, warningBanner, - apiV1Mode: env.FRACTAL_API_V1_MODE + apiV1Mode: env.FRACTAL_API_V1_MODE, + runnerBackend: env.FRACTAL_RUNNER_BACKEND || 'local' }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2cf731af..1b9d15a2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -196,6 +196,9 @@ diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index 7daf3c18..8346cb11 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -1,114 +1,9 @@

My profile

@@ -119,140 +14,31 @@ - User ID {user.id} - E-mail {user.email} - Username {user.username || '-'} - Active - Superuser - Verified - - - - Slurm user - {user.slurm_user || '-'} - - - - SLURM accounts - - {#if editSlurmAccounts} -
- {#each slurmAccounts as slurmAccount, i} -
- - -
- {/each} - {slurmAccountsError} - -
- {:else if user.slurm_accounts.length > 0} - {#each user.slurm_accounts as account} - {account} -   - {/each} - {:else} - - - {/if} - - - {#if editSlurmAccounts} - - - {:else} - - {/if} - - - - Cache dir - - {#if editCacheDir} -
- { - if (e.key === 'Enter') { - saveCacheDir(); - } - }} - /> - - - {#if cacheDirError} -
{cacheDirError}
- {/if} -
- {:else} - {user.cache_dir || '-'} - {/if} - - - {#if !editCacheDir} - - {/if} - Groups @@ -261,11 +47,10 @@ {group} {/each} - OAuth2 accounts - + {#if user.oauth_accounts.length === 0} - {:else} @@ -282,6 +67,5 @@ -
diff --git a/src/routes/settings/+page.server.js b/src/routes/settings/+page.server.js new file mode 100644 index 00000000..62266e59 --- /dev/null +++ b/src/routes/settings/+page.server.js @@ -0,0 +1,16 @@ +import { env } from '$env/dynamic/private'; +import { getCurrentUserSettings } from '$lib/server/api/auth_api.js'; +import { getLogger } from '$lib/server/logger.js'; + +const logger = getLogger('settings page'); + +export async function load({ fetch }) { + logger.trace('Load settings page'); + + const settings = await getCurrentUserSettings(fetch); + + return { + settings, + runnerBackend: env.FRACTAL_RUNNER_BACKEND || 'local' + }; +} diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 00000000..c03fb053 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,178 @@ + + +

My settings

+ +{#if $page.data.runnerBackend !== 'local' && $page.data.runnerBackend !== 'local_experimental'} + {#if $page.data.runnerBackend === 'slurm'} +
+
SLURM user
+
+ {settings.slurm_user || '-'} +
+
+ {/if} + {#if $page.data.runnerBackend === 'slurm_ssh'} +
+
SSH username
+
+ {settings.ssh_username || '-'} +
+
+ {/if} +
+
SLURM accounts
+
+
+ {#each slurmAccounts as slurmAccount, i} +
+ + +
+ {/each} + {slurmAccountsError} + +
+
+
+ +
+ +
+
+ + {#if cacheDirError} +
{cacheDirError}
+ {/if} +
+
+
+ +
+
+
+ + +
+
+{/if} diff --git a/src/routes/v1/projects/[projectId]/workflows/[workflowId]/+page.svelte b/src/routes/v1/projects/[projectId]/workflows/[workflowId]/+page.svelte index 0f0cac0e..acaa5c32 100644 --- a/src/routes/v1/projects/[projectId]/workflows/[workflowId]/+page.svelte +++ b/src/routes/v1/projects/[projectId]/workflows/[workflowId]/+page.svelte @@ -37,8 +37,9 @@ let inputDatasetControl = ''; let outputDatasetControl = ''; let setSlurmAccount = true; - let slurmAccount = - $page.data.userInfo.slurm_accounts.length === 0 ? '' : $page.data.userInfo.slurm_accounts[0]; + /** @type {string[]} */ + let slurmAccounts = []; + let slurmAccount = ''; let workerInitControl = ''; let firstTaskIndexControl = ''; let lastTaskIndexControl = ''; @@ -74,11 +75,26 @@ $: updatableWorkflowList = workflow?.task_list || []; + async function loadSlurmAccounts() { + const response = await fetch(`/api/auth/current-user/settings`, { + method: 'GET', + credentials: 'include' + }); + const result = await response.json(); + if (response.ok) { + slurmAccounts = result.slurm_accounts; + slurmAccount = slurmAccounts.length === 0 ? '' : slurmAccounts[0]; + } else { + console.error('Error while loading current user settings', result); + } + } + onMount(async () => { workflow = /** @type {import('$lib/types').Workflow} */ ($page.data.workflow); project = workflow.project; datasets = $page.data.datasets; - checkNewVersions(); + await checkNewVersions(); + await loadSlurmAccounts(); }); beforeNavigate((navigation) => { @@ -1019,7 +1035,7 @@ bind:value={workerInitControl} />
- {#if $page.data.userInfo.slurm_accounts.length > 0} + {#if slurmAccounts.length > 0}
- {#each $page.data.userInfo.slurm_accounts as account} + {#each slurmAccounts as account} {/each} diff --git a/src/routes/v2/admin/tasks/+page.server.js b/src/routes/v2/admin/tasks/+page.server.js deleted file mode 100644 index 1654fa5a..00000000 --- a/src/routes/v2/admin/tasks/+page.server.js +++ /dev/null @@ -1,20 +0,0 @@ -import { listUsers } from '$lib/server/api/auth_api.js'; -import { getLogger } from '$lib/server/logger.js'; - -const logger = getLogger('admin tasks page [v2]'); - -export async function load({ fetch }) { - logger.trace('Loading admin tasks page'); - - const usersList = await listUsers(fetch); - - const users = /** @type {string[]} */ ([ - ...new Set(usersList.map((u) => (u.username ? u.username : u.slurm_user)).filter((u) => !!u)) - ]); - - users.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); - - return { - users - }; -} diff --git a/src/routes/v2/admin/tasks/+page.svelte b/src/routes/v2/admin/tasks/+page.svelte index 7bf7a8e7..2e93d7fc 100644 --- a/src/routes/v2/admin/tasks/+page.svelte +++ b/src/routes/v2/admin/tasks/+page.svelte @@ -1,5 +1,4 @@