From 530d896725d533a3be83a7781c70ee93fb6fb6d8 Mon Sep 17 00:00:00 2001 From: dave vader <48764154+plyr4@users.noreply.github.com> Date: Tue, 14 May 2024 09:53:07 -0500 Subject: [PATCH] feat: admin page for workers and db driven platform settings (#794) Co-authored-by: ecrupper --- cypress/fixtures/auth_admin.json | 3 + cypress/fixtures/settings.json | 16 + cypress/fixtures/settings_updated.json | 16 + cypress/fixtures/user_admin.json | 7 + cypress/fixtures/workers_10a.json | 172 +++ cypress/fixtures/workers_10b.json | 142 ++ cypress/fixtures/workers_5.json | 102 ++ cypress/integration/admin_settings.spec.js | 241 ++++ cypress/integration/admin_workers.spec.js | 109 ++ cypress/support/commands.js | 29 + src/elm/Api/Endpoint.elm | 8 + src/elm/Api/Operations.elm | 53 + src/elm/Components/Form.elm | 379 ++++- src/elm/Components/Header.elm | 20 +- src/elm/Components/SecretForm.elm | 2 +- src/elm/Components/Tabs.elm | 33 +- src/elm/Effect.elm | 76 +- src/elm/Layouts.elm | 5 + src/elm/Layouts/Default/Admin.elm | 197 +++ src/elm/Layouts/Default/Build.elm | 2 +- src/elm/Layouts/Default/Org.elm | 2 +- src/elm/Layouts/Default/Repo.elm | 4 +- src/elm/Main.elm | 366 +++++ src/elm/Main/Layouts/Model.elm | 2 + src/elm/Main/Layouts/Msg.elm | 2 + src/elm/Main/Pages/Model.elm | 4 + src/elm/Main/Pages/Msg.elm | 4 + src/elm/Pages/Account/Logout.elm | 6 + src/elm/Pages/Account/SourceRepos.elm | 2 +- src/elm/Pages/Admin/Settings.elm | 1274 +++++++++++++++++ src/elm/Pages/Admin/Workers.elm | 430 ++++++ .../Dash/Secrets/Engine_/Org/Org_/Add.elm | 4 +- .../Dash/Secrets/Engine_/Org/Org_/Name_.elm | 4 +- .../Secrets/Engine_/Repo/Org_/Repo_/Add.elm | 4 +- .../Secrets/Engine_/Repo/Org_/Repo_/Name_.elm | 4 +- .../Secrets/Engine_/Shared/Org_/Team_/Add.elm | 6 +- .../Engine_/Shared/Org_/Team_/Name_.elm | 4 +- src/elm/Pages/Home_.elm | 4 +- src/elm/Pages/Org_/Repo_/Deployments.elm | 2 +- src/elm/Pages/Org_/Repo_/Deployments/Add.elm | 12 +- src/elm/Pages/Org_/Repo_/Hooks.elm | 6 +- src/elm/Pages/Org_/Repo_/Schedules/Add.elm | 6 +- src/elm/Pages/Org_/Repo_/Schedules/Name_.elm | 6 +- src/elm/Route/Path.elm | 14 + src/elm/Shared.elm | 4 +- src/elm/Shared/Msg.elm | 2 +- src/elm/Vela.elm | 274 +++- src/scss/_forms.scss | 29 + src/scss/_settings.scss | 21 +- src/scss/_table.scss | 25 +- 50 files changed, 4075 insertions(+), 64 deletions(-) create mode 100644 cypress/fixtures/auth_admin.json create mode 100644 cypress/fixtures/settings.json create mode 100644 cypress/fixtures/settings_updated.json create mode 100644 cypress/fixtures/user_admin.json create mode 100644 cypress/fixtures/workers_10a.json create mode 100644 cypress/fixtures/workers_10b.json create mode 100644 cypress/fixtures/workers_5.json create mode 100644 cypress/integration/admin_settings.spec.js create mode 100644 cypress/integration/admin_workers.spec.js create mode 100644 src/elm/Layouts/Default/Admin.elm create mode 100644 src/elm/Pages/Admin/Settings.elm create mode 100644 src/elm/Pages/Admin/Workers.elm diff --git a/cypress/fixtures/auth_admin.json b/cypress/fixtures/auth_admin.json new file mode 100644 index 000000000..e6d37986e --- /dev/null +++ b/cypress/fixtures/auth_admin.json @@ -0,0 +1,3 @@ +{ + "token": "header.eyJpc19hZG1pbiI6ZmFsc2UsImlzX2FjdGl2ZSI6dHJ1ZSwiZXhwIjoxNjA2MjA4MDAzLCJpYXQiOjE2MDYyMDc5NDMsInN1YiI6ImNvb2tpZSBjYXQifQ==.signature" +} diff --git a/cypress/fixtures/settings.json b/cypress/fixtures/settings.json new file mode 100644 index 000000000..2272dbfd9 --- /dev/null +++ b/cypress/fixtures/settings.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "compiler": { + "clone_image": "target/vela-git:latest", + "template_depth": 10, + "starlark_exec_limit": 500 + }, + "queue": { + "routes": ["vela"] + }, + "repo_allowlist": ["octocat/hello-world"], + "schedule_allowlist": ["*"], + "created_at": 1572980375, + "updated_at": 1572980675, + "updated_by": "octocat" +} diff --git a/cypress/fixtures/settings_updated.json b/cypress/fixtures/settings_updated.json new file mode 100644 index 000000000..3ba46a684 --- /dev/null +++ b/cypress/fixtures/settings_updated.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "compiler": { + "clone_image": "target/vela-git:abc123", + "template_depth": 1, + "starlark_exec_limit": 5 + }, + "queue": { + "routes": ["vela123", "linux-large"] + }, + "repo_allowlist": ["octocat/hello-world"], + "schedule_allowlist": [], + "created_at": 1572980375, + "updated_at": 1572980675, + "updated_by": "octocat" +} diff --git a/cypress/fixtures/user_admin.json b/cypress/fixtures/user_admin.json new file mode 100644 index 000000000..9c23bfb46 --- /dev/null +++ b/cypress/fixtures/user_admin.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "name": "octocat", + "favorites": [], + "active": true, + "admin": true +} diff --git a/cypress/fixtures/workers_10a.json b/cypress/fixtures/workers_10a.json new file mode 100644 index 000000000..6b0163769 --- /dev/null +++ b/cypress/fixtures/workers_10a.json @@ -0,0 +1,172 @@ +[ + { + "id": 1, + "hostname": "worker_1", + "address": "http://vela:8080", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "busy", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 2, + "running_builds": [ + { + "id": 2, + "repo_id": 1, + "number": 2, + "parent": 1, + "event": "push", + "status": "running", + "error": "", + "enqueued": 1563474204, + "created": 1563474204, + "started": 1563474204, + "finished": 0, + "deploy": "", + "clone": "/~https://github.com/github/octocat.git", + "source": "/~https://github.com/github/octocat/commit/48afb5bdc41ad69bf22588491333f7cf71135163", + "title": "push received from /~https://github.com/github/octocat", + "message": "Second commit...", + "commit": "48afb5bdc41ad69bf22588491333f7cf71135163", + "sender": "OctoKitty", + "author": "OctoKitty", + "email": "octokitty@github.com", + "link": "https://vela.example.company.com/github/octocat/1", + "branch": "main", + "ref": "refs/heads/main", + "base_ref": "", + "host": "ed95dcc0687c", + "runtime": "", + "distribution": "" + } + ] + }, + { + "id": 2, + "hostname": "worker_2", + "address": "http://vela:8082", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "available", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 2, + "running_builds": [] + }, + { + "id": 3, + "hostname": "worker_3", + "address": "http://vela:8083", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 3, + "running_builds": [] + }, + { + "id": 4, + "hostname": "worker_4", + "address": "http://vela:8084", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 4, + "running_builds": [] + }, + { + "id": 5, + "hostname": "worker_5", + "address": "http://vela:8085", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "error", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 5, + "running_builds": [] + }, + { + "id": 6, + "hostname": "worker_6", + "address": "http://vela:8086", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 6, + "running_builds": [] + }, + { + "id": 7, + "hostname": "worker_7", + "address": "http://vela:8087", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 7, + "running_builds": [] + }, + { + "id": 8, + "hostname": "worker_8", + "address": "http://vela:8088", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 8, + "running_builds": [] + }, + { + "id": 9, + "hostname": "worker_9", + "address": "http://vela:8089", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 9, + "running_builds": [] + }, + { + "id": 10, + "hostname": "worker_10", + "address": "http://vela:8090", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 10, + "running_builds": [] + } +] diff --git a/cypress/fixtures/workers_10b.json b/cypress/fixtures/workers_10b.json new file mode 100644 index 000000000..124d42bcd --- /dev/null +++ b/cypress/fixtures/workers_10b.json @@ -0,0 +1,142 @@ +[ + { + "id": 11, + "hostname": "worker_11", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 12, + "hostname": "worker_12", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 13, + "hostname": "worker_13", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 14, + "hostname": "worker_14", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 15, + "hostname": "worker_15", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 16, + "hostname": "worker_16", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 17, + "hostname": "worker_17", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 18, + "hostname": "worker_18", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 19, + "hostname": "worker_19", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 160261, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + }, + { + "id": 20, + "hostname": "worker_20", + "address": "http://vela:8091", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 160261, + "last_build_finished_at": 1602612590, + "build_limit": 11, + "running_builds": [] + } +] diff --git a/cypress/fixtures/workers_5.json b/cypress/fixtures/workers_5.json new file mode 100644 index 000000000..a34640c3a --- /dev/null +++ b/cypress/fixtures/workers_5.json @@ -0,0 +1,102 @@ +[ + { + "id": 1, + "hostname": "worker_1", + "address": "http://vela:8080", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "busy", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 2, + "running_builds": [ + { + "id": 2, + "repo_id": 1, + "number": 2, + "parent": 1, + "event": "push", + "status": "running", + "error": "", + "enqueued": 1563474204, + "created": 1563474204, + "started": 1563474204, + "finished": 0, + "deploy": "", + "clone": "/~https://github.com/github/octocat.git", + "source": "/~https://github.com/github/octocat/commit/48afb5bdc41ad69bf22588491333f7cf71135163", + "title": "push received from /~https://github.com/github/octocat", + "message": "Second commit...", + "commit": "48afb5bdc41ad69bf22588491333f7cf71135163", + "sender": "OctoKitty", + "author": "OctoKitty", + "email": "octokitty@github.com", + "link": "https://vela.example.company.com/github/octocat/1", + "branch": "main", + "ref": "refs/heads/main", + "base_ref": "", + "host": "ed95dcc0687c", + "runtime": "", + "distribution": "" + } + ] + }, + { + "id": 2, + "hostname": "worker_2", + "address": "http://vela:8082", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "available", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 2, + "running_builds": [] + }, + { + "id": 3, + "hostname": "worker_3", + "address": "http://vela:8083", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 3, + "running_builds": [] + }, + { + "id": 4, + "hostname": "worker_4", + "address": "http://vela:8084", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "idle", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 4, + "running_builds": [] + }, + { + "id": 5, + "hostname": "worker_5", + "address": "http://vela:8085", + "routes": ["large", "docker", "large:docker"], + "active": true, + "last_checked_in": 1602612590, + "status": "error", + "last_status_update_at": 1602612590, + "last_build_started_at": 1602612590, + "last_build_finished_at": 1602612590, + "build_limit": 5, + "running_builds": [] + } +] diff --git a/cypress/integration/admin_settings.spec.js b/cypress/integration/admin_settings.spec.js new file mode 100644 index 000000000..24515abfc --- /dev/null +++ b/cypress/integration/admin_settings.spec.js @@ -0,0 +1,241 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +context('Admin Settings', () => { + beforeEach(() => { + cy.server(); + cy.route({ + method: 'GET', + url: '*api/v1/user*', + status: 200, + response: 'fixture:user_admin.json', + }); + }); + context('server returning error', () => { + beforeEach(() => { + cy.route({ + method: 'GET', + url: '*api/v1/admin/settings*', + status: 500, + }); + cy.loginAdmin('/admin/settings'); + }); + it('should show an error', () => { + cy.get('[data-test=alert]').should('be.visible').contains('Error'); + }); + }); + context('server returning settings', () => { + beforeEach(() => { + cy.route({ + method: 'GET', + url: '*api/v1/admin/settings*', + status: 200, + response: 'fixture:settings.json', + }); + cy.loginAdmin('/admin/settings'); + }); + it('compiler clone image should show', () => { + cy.get('[data-test=input-clone-image]').should('be.visible'); + }); + it('compiler template depth should show', () => { + cy.get('[data-test=input-template-depth]').should('be.visible'); + }); + it('compiler starlark exec limit should show', () => { + cy.get('[data-test=input-starlark-exec-limit]').should('be.visible'); + }); + it('queue routes list should show', () => { + cy.get('[data-test=editable-list-queue-routes]') + .should('be.visible') + .within(() => { + cy.get('[data-test=editable-list-item-vela]').should( + 'contain', + 'vela', + ); + }); + }); + + context('form should allow editing', () => { + beforeEach(() => { + cy.route({ + method: 'PUT', + url: '*api/v1/admin/settings*', + status: 200, + response: 'fixture:settings_updated.json', + }); + }); + it('clone image should allow editing', () => { + cy.get('[data-test=input-clone-image]') + .should('be.visible') + .clear() + .type('target/vela-git:abc123'); + cy.get('[data-test=button-clone-image-update]').click(); + cy.get('[data-test=alert]').should('be.visible').contains('Success'); + cy.get('[data-test=input-clone-image]') + .should('be.visible') + .should('have.value', 'target/vela-git:abc123'); + }); + it('editing above or below a limit should disable button', () => { + cy.get('[data-test=input-template-depth]') + .should('be.visible') + .clear() + .type('999999'); + cy.get('[data-test=button-template-depth-update]').should( + 'be.disabled', + ); + + cy.get('[data-test=input-template-depth]') + .should('be.visible') + .type('0'); + cy.get('[data-test=button-template-depth-update]').should( + 'be.disabled', + ); + }); + context('list item should allow editing', () => { + it('edit button should toggle save and remove buttons', () => { + cy.get('[data-test=editable-list-queue-routes]') + .should('be.visible') + .within(() => { + cy.get('[data-test=editable-list-item-vela-edit]').should( + 'be.visible', + ); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela-remove]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela-edit]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=editable-list-item-vela-edit]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela-remove]').should( + 'be.visible', + ); + cy.get('[data-test=editable-list-item-vela-save]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + }); + }); + it('save button should skip non-edits', () => { + cy.get('[data-test=editable-list-queue-routes]') + .should('be.visible') + .within(() => { + cy.get('[data-test=editable-list-item-vela-edit]').should( + 'be.visible', + ); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela-edit]') + .should('be.visible') + .click({ force: true }); + // no change edit + cy.get('[data-test=editable-list-item-vela-save]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela]').should( + 'contain', + 'vela', + ); + // empty string edit + cy.get('[data-test=editable-list-item-vela-edit]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=input-editable-list-item-vela]').clear(); + cy.get('[data-test=editable-list-item-vela-save]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela]').should( + 'contain', + 'vela', + ); + cy.get('[data-test=alert]').should('not.be.visible'); + }); + }); + it('save button should save edits', () => { + cy.get('[data-test=editable-list-queue-routes]') + .should('be.visible') + .within(() => { + cy.get('[data-test=editable-list-item-vela-edit]').should( + 'be.visible', + ); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela-edit]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=input-editable-list-item-vela]') + .clear() + .type('vela123'); + cy.get('[data-test=editable-list-item-vela-save]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test=editable-list-item-vela-save]').should( + 'not.be.visible', + ); + cy.get('[data-test=editable-list-item-vela123]').should( + 'contain', + 'vela123', + ); + }); + }); + it('remove button should remove an item', () => { + cy.get('[data-test=editable-list-schedule-allowlist]') + .should('be.visible') + .within(() => { + cy.get('[data-test="editable-list-item-*-edit"]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test="editable-list-item-*-remove"]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test="editable-list-item-*"]').should( + 'not.be.visible', + ); + cy.get( + '[data-test=editable-list-schedule-allowlist-no-items]', + ).should('be.visible'); + }); + cy.get('[data-test=alert]').should('be.visible').contains('Success'); + }); + it('* repo wildcard should show helpful text', () => { + cy.get('[data-test=editable-list-schedule-allowlist]') + .should('be.visible') + .within(() => { + cy.get('[data-test="editable-list-item-*"]').should( + 'contain', + 'all repos', + ); + }); + }); + it('add item input header should add items', () => { + cy.get('[data-test="editable-list-item-linux-large"]').should( + 'not.be.visible', + ); + cy.get('[data-test=input-editable-list-queue-routes-add]') + .clear() + .type('linux-large'); + cy.get('[data-test=button-editable-list-queue-routes-add]') + .should('be.visible') + .click({ force: true }); + cy.get('[data-test="editable-list-item-linux-large"]').should( + 'be.visible', + ); + }); + }); + }); + }); +}); diff --git a/cypress/integration/admin_workers.spec.js b/cypress/integration/admin_workers.spec.js new file mode 100644 index 000000000..fee23f4d0 --- /dev/null +++ b/cypress/integration/admin_workers.spec.js @@ -0,0 +1,109 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +context('Workers', () => { + beforeEach(() => { + cy.server(); + cy.route({ + method: 'GET', + url: '*api/v1/user*', + status: 200, + response: 'fixture:user_admin.json', + }); + }); + context('server returning workers error', () => { + beforeEach(() => { + cy.route({ + method: 'GET', + url: '*api/v1/workers*', + status: 500, + response: 'server error', + }); + cy.loginAdmin('/admin/workers'); + }); + it('workers table should not show', () => { + cy.get('[data-test=workers]').should('not.be.visible'); + }); + it('error should show', () => { + cy.get('[data-test=alerts]').should('exist').contains('Error'); + }); + it('error banner should show', () => { + cy.get('[data-test=workers-error]') + .should('exist') + .contains('there was an error'); + }); + }); + context('server returning 5 workers', () => { + beforeEach(() => { + cy.server(); + cy.route('GET', '*api/v1/workers*', 'fixture:workers_5.json').as( + 'workers', + ); + cy.loginAdmin('/admin/workers'); + }); + it('workers table should show', () => { + cy.get('[data-test=workers-table]').should('be.visible'); + }); + it('workers table should show 5 workers', () => { + cy.get('[data-test=workers-row]').should('have.length', 5); + }); + it('pagination controls should not show', () => { + cy.get('[data-test=pager-previous]').should('not.be.visible'); + }); + context('worker', () => { + beforeEach(() => { + cy.get('[data-test=workers-row]').first().as('firstWorker'); + cy.get('[data-test=workers-row]').last().as('lastWorker'); + cy.get('[data-test=workers-row]').last().prev().prev().as('skipWorker'); + }); + it('should show status', () => { + cy.get('@firstWorker').within(() => { + cy.get('[data-test=cell-status]').contains('busy'); + }); + }); + context('error', () => { + it('should have error styles', () => { + cy.get('@lastWorker').should('have.class', 'status-error'); + }); + }); + }); + }); + context('server returning 10 workers', () => { + beforeEach(() => { + cy.server(); + cy.workerPages(); + cy.loginAdmin('/admin/workers'); + }); + it('workers table should show 10 workers', () => { + cy.get('[data-test=workers-row]').should('have.length', 10); + }); + it('shows page 2 of the workers', () => { + cy.visit('/admin/workers?page=2'); + cy.get('[data-test=workers-row]').should('have.length', 10); + cy.get('[data-test=pager-next]').should('be.disabled'); + }); + it("loads the first page when hitting the 'previous' button", () => { + cy.visit('/admin/workers?page=2'); + cy.get('[data-test=pager-previous]') + .should('have.length', 2) + .first() + .click(); + cy.location('pathname').should('eq', '/admin/workers'); + }); + context('force 550, 750 resolution', () => { + beforeEach(() => { + cy.viewport(550, 750); + }); + // TODO: skip test for now; fix by updating to newer cypress/playwright + it.skip('rows have responsive style', () => { + cy.get('[data-test=workers-row]') + .first() + .should('have.css', 'border-bottom', '2px solid rgb(149, 94, 166)'); // check for lavender border + cy.get('[data-test=workers-table]') + .first() + .should('have.css', 'border', '0px none rgb(250, 250, 250)'); // no base border + }); + }); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 31e018251..ed6de0ea5 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -26,6 +26,13 @@ Cypress.Commands.add('login', (path = '/') => { cy.visit(path); }); +// Login helper for site admin auth (accepts initial path to visit) +Cypress.Commands.add('loginAdmin', (path = '/') => { + cy.server(); + cy.route('/token-refresh*', 'fixture:auth_admin.json'); + cy.visit(path); +}); + // Faking the act of logging in helper Cypress.Commands.add('loggingIn', (path = '/') => { cy.server(); @@ -621,6 +628,28 @@ Cypress.Commands.add('redeliverHookError', () => { }); }); +Cypress.Commands.add('workerPages', () => { + cy.server(); + cy.fixture('workers_10a.json').as('workersPage1'); + cy.fixture('workers_10b.json').as('workersPage2'); + cy.route({ + method: 'GET', + url: '*api/v1/workers*', + headers: { + link: `; rel="next", ; rel="last",`, + }, + response: '@workersPage1', + }); + cy.route({ + method: 'GET', + url: '*api/v1/workers?page=2*', + headers: { + link: `; rel="first", ; rel="prev",`, + }, + response: '@workersPage2', + }); +}); + Cypress.Commands.add('checkA11yForPage', (path = '/', opts = {}) => { cy.login(path); cy.injectAxe(); diff --git a/src/elm/Api/Endpoint.elm b/src/elm/Api/Endpoint.elm index 4de362b74..8802046a1 100644 --- a/src/elm/Api/Endpoint.elm +++ b/src/elm/Api/Endpoint.elm @@ -53,6 +53,8 @@ type Endpoint | PipelineConfig Vela.Org Vela.Repo Vela.Ref | ExpandPipelineConfig Vela.Org Vela.Repo Vela.Ref | PipelineTemplates Vela.Org Vela.Repo Vela.Ref + | Workers (Maybe Pagination.Page) (Maybe Pagination.PerPage) + | Settings {-| toUrl : turns and Endpoint into a URL string. @@ -161,6 +163,12 @@ toUrl api endpoint = Deployments maybePage maybePerPage org repo -> url api [ "deployments", org, repo ] <| Pagination.toQueryParams maybePage maybePerPage + Workers maybePage maybePerPage -> + url api [ "workers" ] <| Pagination.toQueryParams maybePage maybePerPage + + Settings -> + url api [ "admin", "settings" ] [] + {-| url : creates a URL string with the given path segments and query parameters. -} diff --git a/src/elm/Api/Operations.elm b/src/elm/Api/Operations.elm index 463013447..8a00acdad 100644 --- a/src/elm/Api/Operations.elm +++ b/src/elm/Api/Operations.elm @@ -41,10 +41,12 @@ module Api.Operations exposing , getRepoSchedules , getRepoSecret , getRepoSecrets + , getSettings , getSharedSecret , getSharedSecrets , getToken , getUserSourceRepos + , getWorkers , logout , redeliverHook , repairRepo @@ -54,6 +56,7 @@ module Api.Operations exposing , updateRepo , updateRepoSchedule , updateRepoSecret + , updateSettings , updateSharedSecret ) @@ -287,6 +290,56 @@ getRepoBuilds baseUrl session options = |> withAuth session +{-| getWorkers : retrieves workers. +-} +getWorkers : + String + -> Session + -> + { a + | pageNumber : Maybe Int + , perPage : Maybe Int + } + -> Request (List Vela.Worker) +getWorkers baseUrl session options = + get baseUrl + (Api.Endpoint.Workers + options.pageNumber + options.perPage + ) + Vela.decodeWorkers + |> withAuth session + + +{-| getSettings : retrieves the active settings record for the platform. +-} +getSettings : + String + -> Session + -> a + -> Request Vela.PlatformSettings +getSettings baseUrl session _ = + get baseUrl + Api.Endpoint.Settings + Vela.decodeSettings + |> withAuth session + + +{-| updateSettings : updates the active settings record for the platform. +-} +updateSettings : + String + -> Session + -> { a | body : Http.Body } + -> Request Vela.PlatformSettings +updateSettings baseUrl session options = + put baseUrl + Api.Endpoint.Settings + options.body + Vela.decodeSettings + |> withAuth session + + {-| restartBuild : restarts a build. -} restartBuild : diff --git a/src/elm/Components/Form.elm b/src/elm/Components/Form.elm index 0f5da4c46..06ef90c09 100644 --- a/src/elm/Components/Form.elm +++ b/src/elm/Components/Form.elm @@ -3,12 +3,17 @@ SPDX-License-Identifier: Apache-2.0 --} -module Components.Form exposing (viewAllowEvents, viewButton, viewCheckbox, viewInput, viewRadio, viewSubtitle, viewTextarea) +module Components.Form exposing (EditableListForm, handleNumberInputString, viewAllowEvents, viewButton, viewCheckbox, viewCopyButton, viewEditableList, viewInput, viewInputSection, viewNumberInput, viewRadio, viewSubtitle, viewTextarea, viewTextareaSection) -import Html exposing (Html, button, div, h3, input, label, section, span, strong, text, textarea) -import Html.Attributes exposing (checked, class, classList, disabled, for, id, placeholder, rows, type_, value, wrap) +import Components.Loading +import Dict exposing (Dict) +import FeatherIcons +import Html exposing (Html, button, div, h3, input, label, li, section, span, strong, text, textarea, ul) +import Html.Attributes exposing (attribute, checked, class, classList, disabled, for, id, placeholder, rows, type_, value, wrap) import Html.Events exposing (onCheck, onClick, onInput) +import Http import Maybe.Extra +import RemoteData exposing (WebData) import Shared import Utils.Helpers as Util import Vela @@ -18,9 +23,9 @@ import Vela -- VIEW -{-| viewInput : renders the HTML for an input field. +{-| viewInputSection : renders the HTML for an input field in a section. -} -viewInput : +viewInputSection : { id_ : String , title : Maybe String , subtitle : Maybe (Html msg) @@ -33,7 +38,7 @@ viewInput : , disabled_ : Bool } -> Html msg -viewInput { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_, msg, disabled_ } = +viewInputSection { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_, msg, disabled_ } = let target = String.join "-" [ "input", id_ ] @@ -63,22 +68,123 @@ viewInput { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_, m ] -{-| viewTextarea : renders the HTML for a text area field. +{-| viewInput : renders the HTML for an input field within a section. -} -viewTextarea : +viewInput : + { id_ : String + , title : Maybe String + , subtitle : Maybe (Html msg) + , val : String + , placeholder_ : String + , wrapperClassList : List ( String, Bool ) + , classList_ : List ( String, Bool ) + , rows_ : Maybe Int + , wrap_ : Maybe String + , msg : String -> msg + , disabled_ : Bool + } + -> Html msg +viewInput { id_, title, subtitle, val, placeholder_, classList_, wrapperClassList, rows_, wrap_, msg, disabled_ } = + let + target = + String.join "-" [ "input", id_ ] + in + div + [ class "form-control" + , classList wrapperClassList + ] + [ input + [ id target + , value val + , placeholder placeholder_ + , classList <| classList_ + , Maybe.Extra.unwrap Util.attrNone rows rows_ + , Maybe.Extra.unwrap Util.attrNone wrap wrap_ + , onInput msg + , disabled disabled_ + , Util.testAttribute target + ] + [] + ] + + +{-| handleNumberInputString : returns a value as a number if it can be converted, otherwise returns the current value. +-} +handleNumberInputString : String -> String -> String +handleNumberInputString current val = + case String.toInt val of + Just _ -> + val + + Nothing -> + if not <| String.isEmpty val then + current + + else + "" + + +{-| viewNumberInput : renders the HTML for an input expected to handle numbers. +-} +viewNumberInput : { id_ : String , title : Maybe String , subtitle : Maybe (Html msg) , val : String , placeholder_ : String + , wrapperClassList : List ( String, Bool ) , classList_ : List ( String, Bool ) , rows_ : Maybe Int , wrap_ : Maybe String , msg : String -> msg , disabled_ : Bool + , min : Maybe Int + , max : Maybe Int } -> Html msg -viewTextarea { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_, msg, disabled_ } = +viewNumberInput { id_, title, subtitle, val, placeholder_, wrapperClassList, classList_, rows_, wrap_, msg, disabled_, min, max } = + let + target = + String.join "-" [ "input", id_ ] + in + div + [ class "form-control" + , classList wrapperClassList + ] + [ input + [ id target + , type_ "number" + , Maybe.Extra.unwrap Util.attrNone (String.fromInt >> Html.Attributes.min) min + , Maybe.Extra.unwrap Util.attrNone (String.fromInt >> Html.Attributes.max) max + , value val + , placeholder placeholder_ + , classList <| classList_ + , Maybe.Extra.unwrap Util.attrNone rows rows_ + , Maybe.Extra.unwrap Util.attrNone wrap wrap_ + , onInput msg + , disabled disabled_ + , Util.testAttribute target + ] + [] + ] + + +{-| viewTextareaSection : renders the HTML for a textarea within a section. +-} +viewTextareaSection : + { id_ : String + , title : Maybe String + , subtitle : Maybe (Html msg) + , val : String + , placeholder_ : String + , classList_ : List ( String, Bool ) + , rows_ : Maybe Int + , wrap_ : Maybe String + , msg : String -> msg + , disabled_ : Bool + } + -> Html msg +viewTextareaSection { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_, msg, disabled_ } = let target = String.join "-" [ "textarea", id_ ] @@ -108,6 +214,40 @@ viewTextarea { id_, title, subtitle, val, placeholder_, classList_, rows_, wrap_ ] +{-| viewTextarea : renders a textarea input. +-} +viewTextarea : + { id_ : String + , val : String + , placeholder_ : String + , classList_ : List ( String, Bool ) + , rows_ : Maybe Int + , wrap_ : Maybe String + , msg : String -> msg + , disabled_ : Bool + } + -> Html msg +viewTextarea { id_, val, placeholder_, classList_, rows_, wrap_, msg, disabled_ } = + let + target = + String.join "-" [ "textarea", id_ ] + in + div [ class "form-control" ] + [ textarea + [ id target + , value val + , placeholder placeholder_ + , classList classList_ + , Maybe.Extra.unwrap Util.attrNone rows rows_ + , Maybe.Extra.unwrap Util.attrNone wrap wrap_ + , onInput msg + , disabled disabled_ + , Util.testAttribute target + ] + [] + ] + + {-| viewCheckbox : renders the HTML for a checkbox. -} viewCheckbox : @@ -193,6 +333,33 @@ viewButton { id_, msg, text_, classList_, disabled_ } = [ text text_ ] +{-| viewCopyButton : renders a copy to clipboard button +-} +viewCopyButton : { id_ : String, msg : String -> msg, text_ : String, classList_ : List ( String, Bool ), disabled_ : Bool, content : String } -> Html msg +viewCopyButton { id_, msg, text_, classList_, disabled_, content } = + let + target = + String.join "-" [ "copy", id_ ] + in + div [] + [ button + [ class "copy-button" + , attribute "aria-label" ("copy " ++ id_ ++ "content to clipboard") + , class "button" + , class "-icon" + , disabled disabled_ + , classList classList_ + , onClick <| msg content + , attribute "data-clipboard-text" content + , Util.testAttribute target + ] + [ FeatherIcons.copy + |> FeatherIcons.withSize 18 + |> FeatherIcons.toHtml [] + ] + ] + + {-| viewSubtitle : renders the HTML for a subtitle. -} viewSubtitle : Maybe (Html msg) -> Html msg @@ -358,3 +525,197 @@ viewAllowEvents shared { msg, allowEvents } = } ] ] + + +{-| EditableListProps : properties for the editable list component. +-} +type alias EditableListProps a b msg = + { id_ : String + , webdata : WebData a + , toItems : a -> List b + , toId : b -> String + , toLabel : b -> String + , addProps : + Maybe + { placeholder_ : String + , addOnInputMsg : String -> msg + , addOnClickMsg : String -> msg + } + , viewHttpError : Http.Error -> Html msg + , viewNoItems : Html msg + , form : EditableListForm + , itemEditOnClickMsg : { id : String } -> msg + , itemSaveOnClickMsg : { id : String, val : String } -> msg + , itemEditOnInputMsg : { id : String } -> String -> msg + , itemRemoveOnClickMsg : String -> msg + } + + +{-| EditableListForm : form values for the editable list component. +-} +type alias EditableListForm = + { val : String + , editing : Dict String String + } + + +{-| viewEditableList : renders an editable list component with optional add button header. +-} +viewEditableList : EditableListProps a b msg -> Html msg +viewEditableList props = + let + target = + String.join "-" [ "editable-list", props.id_ ] + in + div [] + [ case props.addProps of + Just addProps -> + div [ class "form-controls" ] + [ viewInput + { title = Nothing + , subtitle = Nothing + , id_ = target ++ "-add" + , val = props.form.val + , placeholder_ = addProps.placeholder_ + , classList_ = [] + , wrapperClassList = + [ ( "-wide", True ) + ] + , rows_ = Nothing + , wrap_ = Nothing + , msg = addProps.addOnInputMsg + , disabled_ = False + } + , viewButton + { id_ = target ++ "-add" + , msg = addProps.addOnClickMsg props.form.val + , text_ = "add" + , classList_ = + [ ( "-outline", True ) + ] + , disabled_ = + (String.length props.form.val == 0) + || (not <| RemoteData.isSuccess props.webdata) + } + ] + + _ -> + text "" + , div + [ class "editable-list" + , Util.testAttribute target + ] + [ ul [] <| + case props.webdata of + RemoteData.Success data -> + let + items = + props.toItems data + in + if List.isEmpty items then + [ li [ Util.testAttribute <| target ++ "-no-items" ] + [ props.viewNoItems ] + ] + + else + List.map (viewEditableListItem props) items + + RemoteData.Failure error -> + [ li [] + [ span [ Util.testAttribute <| target ++ "-error" ] + [ props.viewHttpError error + ] + ] + ] + + _ -> + [ li [] [ Components.Loading.viewSmallLoader ] ] + ] + ] + + +{-| viewEditableListItem : renders an item for the editable list component. +-} +viewEditableListItem : EditableListProps a b msg -> b -> Html msg +viewEditableListItem props item = + let + itemId = + props.toId item + + target = + String.join "-" [ "editable-list", "item", itemId ] + + editing = + Maybe.Extra.unwrap Nothing (\e -> Just e) <| Dict.get itemId props.form.editing + in + li + [ Util.testAttribute target + ] + [ case editing of + Just val -> + viewInput + { title = Nothing + , subtitle = Nothing + , id_ = target + , val = val + , placeholder_ = props.toLabel item + , wrapperClassList = [] + , classList_ = [] + , rows_ = Nothing + , wrap_ = Nothing + , msg = props.itemEditOnInputMsg { id = itemId } + , disabled_ = False + } + + Nothing -> + span [] [ text <| props.toLabel item ] + , span [] + [ case editing of + Just val -> + span [] + [ button + [ class "remove-button" + , class "button" + , class "-icon" + , attribute "aria-label" <| "remove list item " ++ itemId + , onClick <| props.itemRemoveOnClickMsg <| itemId + , Util.testAttribute <| target ++ "-remove" + , id <| target ++ "-remove" + ] + [ FeatherIcons.minusSquare + |> FeatherIcons.withSize 18 + |> FeatherIcons.toHtml [] + ] + , button + [ class "save-button" + , class "button" + , class "-icon" + , attribute "aria-label" <| "save list item " ++ itemId + , onClick <| props.itemSaveOnClickMsg { id = itemId, val = val } + , Util.testAttribute <| target ++ "-save" + , id <| target ++ "-save" + ] + [ FeatherIcons.save + |> FeatherIcons.withSize 18 + |> FeatherIcons.toHtml [] + ] + ] + + _ -> + span [] + [ button + [ class "edit-button" + , class "button" + , class "-icon" + , attribute "aria-label" <| "edit list item " ++ itemId + , onClick <| props.itemEditOnClickMsg { id = itemId } + , Util.testAttribute <| target ++ "-edit" + , id <| target ++ "-edit" + ] + [ FeatherIcons.edit2 + |> FeatherIcons.withSize 18 + |> FeatherIcons.toHtml [] + ] + ] + ] + ] diff --git a/src/elm/Components/Header.elm b/src/elm/Components/Header.elm index 721cfa0c0..0a61ca32d 100644 --- a/src/elm/Components/Header.elm +++ b/src/elm/Components/Header.elm @@ -13,6 +13,7 @@ import FeatherIcons import Html exposing (Html, a, button, details, div, header, li, nav, summary, text, ul) import Html.Attributes exposing (attribute, class, classList, href, id) import Html.Events exposing (onClick) +import RemoteData import Route import Route.Path import Shared @@ -105,7 +106,24 @@ view shared props = ] , nav [ class "help-links" ] [ ul [] - [ li [] + [ case shared.user of + RemoteData.Success user -> + if user.admin then + li [ class "identity-menu-item" ] + [ a + [ Util.testAttribute "admin-link" + , Route.Path.href Route.Path.Admin_Settings + ] + [ text "site admin" + ] + ] + + else + text "" + + _ -> + text "" + , li [] [ viewThemeToggle props.theme props.setTheme ] , li [] diff --git a/src/elm/Components/SecretForm.elm b/src/elm/Components/SecretForm.elm index 517a6820a..589aa4650 100644 --- a/src/elm/Components/SecretForm.elm +++ b/src/elm/Components/SecretForm.elm @@ -139,7 +139,7 @@ viewImagesInput { onInput_, addImage, removeImage, images, imageValue, disabled_ ] ] , div [ class "parameters-inputs" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Nothing , subtitle = Nothing , id_ = "image-name" diff --git a/src/elm/Components/Tabs.elm b/src/elm/Components/Tabs.elm index f01255347..e0fe8756e 100644 --- a/src/elm/Components/Tabs.elm +++ b/src/elm/Components/Tabs.elm @@ -3,7 +3,7 @@ SPDX-License-Identifier: Apache-2.0 --} -module Components.Tabs exposing (Tab, view, viewBuildTabs, viewOrgTabs, viewRepoTabs) +module Components.Tabs exposing (Tab, view, viewAdminTabs, viewBuildTabs, viewOrgTabs, viewRepoTabs) import Dict exposing (Dict) import Html exposing (Html, a, div, span, text) @@ -308,3 +308,34 @@ viewBuildTabs shared props = ] in view props.tabHistory props.currentPath tabs "jump-bar-build" + + + +-- ADMIN + + +{-| viewAdminTabs : renders tabs available for viewing for the admin page. +-} +viewAdminTabs : + Shared.Model + -> + { currentPath : Route.Path.Path + , tabHistory : Dict String Url + } + -> Html msg +viewAdminTabs shared props = + let + tabs = + [ { name = "Settings" + , toPath = Route.Path.Admin_Settings + , isAlerting = False + , show = True + } + , { name = "Workers" + , toPath = Route.Path.Admin_Workers + , isAlerting = False + , show = True + } + ] + in + view props.tabHistory props.currentPath tabs "jump-bar-admin" diff --git a/src/elm/Effect.elm b/src/elm/Effect.elm index fb29c2363..5073903eb 100644 --- a/src/elm/Effect.elm +++ b/src/elm/Effect.elm @@ -9,7 +9,7 @@ module Effect exposing , sendCmd, sendMsg , pushRoute, replaceRoute, loadExternalUrl , map, toCmd - , addAlertError, addAlertSuccess, addDeployment, addFavorites, addOrgSecret, addRepoSchedule, addRepoSecret, addSharedSecret, alertsUpdate, approveBuild, cancelBuild, chownRepo, clearRedirect, deleteOrgSecret, deleteRepoSchedule, deleteRepoSecret, deleteSharedSecret, disableRepo, downloadFile, enableRepo, expandPipelineConfig, finishAuthentication, focusOn, getBuild, getBuildGraph, getBuildServiceLog, getBuildServices, getBuildStepLog, getBuildSteps, getCurrentUser, getOrgBuilds, getOrgRepos, getOrgSecret, getOrgSecrets, getPipelineConfig, getPipelineTemplates, getRepo, getRepoBuilds, getRepoBuildsShared, getRepoDeployments, getRepoHooks, getRepoHooksShared, getRepoSchedule, getRepoSchedules, getRepoSecret, getRepoSecrets, getSharedSecret, getSharedSecrets, handleHttpError, logout, pushPath, redeliverHook, repairRepo, replacePath, replaceRouteRemoveTabHistorySkipDomFocus, restartBuild, setRedirect, setTheme, updateFavicon, updateFavorite, updateOrgSecret, updateRepo, updateRepoSchedule, updateRepoSecret, updateSharedSecret, updateSourceReposShared + , addAlertError, addAlertSuccess, addDeployment, addFavorites, addOrgSecret, addRepoSchedule, addRepoSecret, addSharedSecret, alertsUpdate, approveBuild, cancelBuild, chownRepo, clearRedirect, deleteOrgSecret, deleteRepoSchedule, deleteRepoSecret, deleteSharedSecret, disableRepo, downloadFile, enableRepo, expandPipelineConfig, finishAuthentication, focusOn, getBuild, getBuildGraph, getBuildServiceLog, getBuildServices, getBuildStepLog, getBuildSteps, getCurrentUser, getCurrentUserShared, getOrgBuilds, getOrgRepos, getOrgSecret, getOrgSecrets, getPipelineConfig, getPipelineTemplates, getRepo, getRepoBuilds, getRepoBuildsShared, getRepoDeployments, getRepoHooks, getRepoHooksShared, getRepoSchedule, getRepoSchedules, getRepoSecret, getRepoSecrets, getSettings, getSharedSecret, getSharedSecrets, getWorkers, handleHttpError, logout, pushPath, redeliverHook, repairRepo, replacePath, replaceRouteRemoveTabHistorySkipDomFocus, restartBuild, setRedirect, setTheme, updateFavicon, updateFavorite, updateOrgSecret, updateRepo, updateRepoSchedule, updateRepoSecret, updateSettings, updateSharedSecret, updateSourceReposShared ) {-| @@ -247,8 +247,24 @@ logout options = SendSharedMsg <| Shared.Msg.Logout options -getCurrentUser : {} -> Effect msg -getCurrentUser _ = +getCurrentUser : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, Vela.User ) -> msg + } + -> Effect msg +getCurrentUser options = + Api.try + options.onResponse + (Api.Operations.getCurrentUser + options.baseUrl + options.session + ) + |> sendCmd + + +getCurrentUserShared : {} -> Effect msg +getCurrentUserShared _ = SendSharedMsg <| Shared.Msg.GetCurrentUser @@ -455,6 +471,60 @@ getRepoDeployments options = |> sendCmd +getWorkers : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Worker ) -> msg + , pageNumber : Maybe Int + , perPage : Maybe Int + } + -> Effect msg +getWorkers options = + Api.try + options.onResponse + (Api.Operations.getWorkers + options.baseUrl + options.session + options + ) + |> sendCmd + + +getSettings : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, Vela.PlatformSettings ) -> msg + } + -> Effect msg +getSettings options = + Api.try + options.onResponse + (Api.Operations.getSettings + options.baseUrl + options.session + options + ) + |> sendCmd + + +updateSettings : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, Vela.PlatformSettings ) -> msg + , body : Http.Body + } + -> Effect msg +updateSettings options = + Api.try + options.onResponse + (Api.Operations.updateSettings + options.baseUrl + options.session + options + ) + |> sendCmd + + restartBuild : { baseUrl : String , session : Auth.Session.Session diff --git a/src/elm/Layouts.elm b/src/elm/Layouts.elm index cea86fed9..2fcfe065d 100644 --- a/src/elm/Layouts.elm +++ b/src/elm/Layouts.elm @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 module Layouts exposing (..) import Layouts.Default +import Layouts.Default.Admin import Layouts.Default.Build import Layouts.Default.Org import Layouts.Default.Repo @@ -13,6 +14,7 @@ import Layouts.Default.Repo type Layout msg = Default Layouts.Default.Props + | Default_Admin (Layouts.Default.Admin.Props msg) | Default_Build (Layouts.Default.Build.Props msg) | Default_Org (Layouts.Default.Org.Props msg) | Default_Repo (Layouts.Default.Repo.Props msg) @@ -24,6 +26,9 @@ map fn layout = Default data -> Default data + Default_Admin data -> + Default_Admin (Layouts.Default.Admin.map fn data) + Default_Build data -> Default_Build (Layouts.Default.Build.map fn data) diff --git a/src/elm/Layouts/Default/Admin.elm b/src/elm/Layouts/Default/Admin.elm new file mode 100644 index 000000000..6e2bba324 --- /dev/null +++ b/src/elm/Layouts/Default/Admin.elm @@ -0,0 +1,197 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Layouts.Default.Admin exposing (Model, Msg, Props, layout, map) + +import Components.Crumbs +import Components.Help +import Components.Loading +import Components.Nav +import Components.Tabs +import Components.Util +import Dict exposing (Dict) +import Effect exposing (Effect) +import Html exposing (Html, main_) +import Html.Attributes exposing (class) +import Http +import Http.Detailed +import Layout exposing (Layout) +import Layouts.Default +import RemoteData +import Route exposing (Route) +import Route.Path +import Shared +import Time +import Url exposing (Url) +import Utils.Errors as Errors +import Utils.Interval as Interval +import Vela +import View exposing (View) + + +type alias Props contentMsg = + { navButtons : List (Html contentMsg) + , utilButtons : List (Html contentMsg) + , helpCommands : List Components.Help.Command + , crumbs : List Components.Crumbs.Crumb + } + + +map : (msg1 -> msg2) -> Props msg1 -> Props msg2 +map fn props = + { navButtons = List.map (Html.map fn) props.navButtons + , utilButtons = List.map (Html.map fn) props.utilButtons + , helpCommands = props.helpCommands + , crumbs = props.crumbs + } + + +layout : Props contentMsg -> Shared.Model -> Route () -> Layout Layouts.Default.Props Model Msg contentMsg +layout props shared route = + Layout.new + { init = init props shared route + , update = update props shared route + , view = view props shared route + , subscriptions = subscriptions + } + |> Layout.withOnUrlChanged OnUrlChanged + |> Layout.withParentProps + { helpCommands = props.helpCommands + } + + + +-- MODEL + + +type alias Model = + { tabHistory : Dict String Url } + + +init : Props contentMsg -> Shared.Model -> Route () -> () -> ( Model, Effect Msg ) +init props shared route _ = + ( { tabHistory = Dict.empty + } + , Effect.batch + [ Effect.getCurrentUserShared {} + , case shared.user of + RemoteData.Success user -> + if not user.admin then + Effect.replacePath Route.Path.Home_ + + else + Effect.none + + _ -> + -- we need to dispatch non-shared effect to receive the callback and check admin status + Effect.getCurrentUser + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetCurrentUserResponse + } + ] + ) + + + +-- UPDATE + + +type Msg + = -- BROWSER + OnUrlChanged { from : Route (), to : Route () } + -- USER + | GetCurrentUserResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.User )) + -- REFRESH + | Tick { time : Time.Posix, interval : Interval.Interval } + + +update : Props contentMsg -> Shared.Model -> Route () -> Msg -> Model -> ( Model, Effect Msg ) +update props shared route msg model = + case msg of + -- BROWSER + OnUrlChanged options -> + ( { model + | tabHistory = + model.tabHistory |> Dict.insert (Route.Path.toString options.to.path) options.to.url + } + , Effect.replaceRouteRemoveTabHistorySkipDomFocus route + ) + + -- USER + GetCurrentUserResponse response -> + case response of + Ok ( _, user ) -> + ( model + , if not user.admin then + Effect.replacePath Route.Path.Home_ + + else + Effect.none + ) + + Err error -> + ( model + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + -- REFRESH + Tick options -> + ( model + , Effect.batch + [ Effect.getCurrentUserShared {} + , Effect.getCurrentUser + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetCurrentUserResponse + } + ] + ) + + +subscriptions : Model -> Sub Msg +subscriptions model = + Interval.tickEveryFiveSeconds Tick + + + +-- VIEW + + +view : Props contentMsg -> Shared.Model -> Route () -> { toContentMsg : Msg -> contentMsg, content : View contentMsg, model : Model } -> View contentMsg +view props shared route { toContentMsg, model, content } = + let + isAdmin = + RemoteData.unwrap False .admin shared.user + in + { title = "Admin " ++ content.title + , body = + [ Components.Nav.view shared + route + { buttons = props.navButtons + , crumbs = Components.Crumbs.view route.path props.crumbs + } + , main_ [ class "content-wrap" ] + (if isAdmin then + Components.Util.view shared + route + (Components.Tabs.viewAdminTabs + shared + { currentPath = route.path + , tabHistory = model.tabHistory + } + :: props.utilButtons + ) + :: content.body + + else + [ Components.Loading.viewSmallLoader + ] + ) + ] + } diff --git a/src/elm/Layouts/Default/Build.elm b/src/elm/Layouts/Default/Build.elm index b63d7f534..a1f72c35f 100644 --- a/src/elm/Layouts/Default/Build.elm +++ b/src/elm/Layouts/Default/Build.elm @@ -100,7 +100,7 @@ init props shared route _ = , tabHistory = Dict.empty } , Effect.batch - [ Effect.getCurrentUser {} + [ Effect.getCurrentUserShared {} , Effect.getRepoBuildsShared { pageNumber = Nothing , perPage = Nothing diff --git a/src/elm/Layouts/Default/Org.elm b/src/elm/Layouts/Default/Org.elm index 4f5cfa51d..933f5a370 100644 --- a/src/elm/Layouts/Default/Org.elm +++ b/src/elm/Layouts/Default/Org.elm @@ -83,7 +83,7 @@ init shared route _ = } , Effect.batch [ Effect.updateFavicon { favicon = Favicons.defaultFavicon } - , Effect.getCurrentUser {} + , Effect.getCurrentUserShared {} ] ) diff --git a/src/elm/Layouts/Default/Repo.elm b/src/elm/Layouts/Default/Repo.elm index 1a2b69f3e..753d7b4ed 100644 --- a/src/elm/Layouts/Default/Repo.elm +++ b/src/elm/Layouts/Default/Repo.elm @@ -88,7 +88,7 @@ init props shared route _ = } , Effect.batch [ Effect.updateFavicon { favicon = Favicons.defaultFavicon } - , Effect.getCurrentUser {} + , Effect.getCurrentUserShared {} , Effect.getRepoBuildsShared { pageNumber = Nothing , perPage = Nothing @@ -150,7 +150,7 @@ update props route msg model = Tick options -> ( model , Effect.batch - [ Effect.getCurrentUser {} + [ Effect.getCurrentUserShared {} , Effect.getRepoBuildsShared { pageNumber = Nothing , perPage = Nothing diff --git a/src/elm/Main.elm b/src/elm/Main.elm index e9543ebc7..cb78afe62 100644 --- a/src/elm/Main.elm +++ b/src/elm/Main.elm @@ -18,6 +18,7 @@ import Json.Encode import Layout import Layouts import Layouts.Default +import Layouts.Default.Admin import Layouts.Default.Build import Layouts.Default.Org import Layouts.Default.Repo @@ -32,6 +33,8 @@ import Pages.Account.Login import Pages.Account.Logout import Pages.Account.Settings import Pages.Account.SourceRepos +import Pages.Admin.Settings +import Pages.Admin.Workers import Pages.Dash.Secrets.Engine_.Org.Org_ import Pages.Dash.Secrets.Engine_.Org.Org_.Add import Pages.Dash.Secrets.Engine_.Org.Org_.Name_ @@ -127,6 +130,11 @@ initLayout model layout = , Cmd.none ) + ( Layouts.Default props, Just (Main.Layouts.Model.Default_Admin existing) ) -> + ( Main.Layouts.Model.Default { default = existing.default } + , Cmd.none + ) + ( Layouts.Default props, Just (Main.Layouts.Model.Default_Build existing) ) -> ( Main.Layouts.Model.Default { default = existing.default } , Cmd.none @@ -158,6 +166,100 @@ initLayout model layout = , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default defaultLayoutEffect) ) + ( Layouts.Default_Admin props, Just (Main.Layouts.Model.Default existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultAdminLayout = + Layouts.Default.Admin.layout props model.shared route + + ( adminLayoutModel, adminLayoutEffect ) = + Layout.init defaultAdminLayout () + in + ( Main.Layouts.Model.Default_Admin { default = existing.default, admin = adminLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Admin adminLayoutEffect) + ) + + ( Layouts.Default_Admin props, Just (Main.Layouts.Model.Default_Admin existing) ) -> + ( Main.Layouts.Model.Default_Admin existing + , Cmd.none + ) + + ( Layouts.Default_Admin props, Just (Main.Layouts.Model.Default_Build existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultAdminLayout = + Layouts.Default.Admin.layout props model.shared route + + ( adminLayoutModel, adminLayoutEffect ) = + Layout.init defaultAdminLayout () + in + ( Main.Layouts.Model.Default_Admin { default = existing.default, admin = adminLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Admin adminLayoutEffect) + ) + + ( Layouts.Default_Admin props, Just (Main.Layouts.Model.Default_Org existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultAdminLayout = + Layouts.Default.Admin.layout props model.shared route + + ( adminLayoutModel, adminLayoutEffect ) = + Layout.init defaultAdminLayout () + in + ( Main.Layouts.Model.Default_Admin { default = existing.default, admin = adminLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Admin adminLayoutEffect) + ) + + ( Layouts.Default_Admin props, Just (Main.Layouts.Model.Default_Repo existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultAdminLayout = + Layouts.Default.Admin.layout props model.shared route + + ( adminLayoutModel, adminLayoutEffect ) = + Layout.init defaultAdminLayout () + in + ( Main.Layouts.Model.Default_Admin { default = existing.default, admin = adminLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Admin adminLayoutEffect) + ) + + ( Layouts.Default_Admin props, _ ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultAdminLayout = + Layouts.Default.Admin.layout props model.shared route + + defaultLayout = + Layouts.Default.layout (Layout.parentProps defaultAdminLayout) model.shared route + + ( adminLayoutModel, adminLayoutEffect ) = + Layout.init defaultAdminLayout () + + ( defaultLayoutModel, defaultLayoutEffect ) = + Layout.init defaultLayout () + in + ( Main.Layouts.Model.Default_Admin { default = defaultLayoutModel, admin = adminLayoutModel } + , Cmd.batch + [ fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Admin adminLayoutEffect) + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default defaultLayoutEffect) + ] + ) + ( Layouts.Default_Build props, Just (Main.Layouts.Model.Default existing) ) -> let route : Route () @@ -174,6 +276,22 @@ initLayout model layout = , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Build buildLayoutEffect) ) + ( Layouts.Default_Build props, Just (Main.Layouts.Model.Default_Admin existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultBuildLayout = + Layouts.Default.Build.layout props model.shared route + + ( buildLayoutModel, buildLayoutEffect ) = + Layout.init defaultBuildLayout () + in + ( Main.Layouts.Model.Default_Build { default = existing.default, build = buildLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Build buildLayoutEffect) + ) + ( Layouts.Default_Build props, Just (Main.Layouts.Model.Default_Build existing) ) -> ( Main.Layouts.Model.Default_Build existing , Cmd.none @@ -252,6 +370,22 @@ initLayout model layout = , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Org orgLayoutEffect) ) + ( Layouts.Default_Org props, Just (Main.Layouts.Model.Default_Admin existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultOrgLayout = + Layouts.Default.Org.layout props model.shared route + + ( orgLayoutModel, orgLayoutEffect ) = + Layout.init defaultOrgLayout () + in + ( Main.Layouts.Model.Default_Org { default = existing.default, org = orgLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Org orgLayoutEffect) + ) + ( Layouts.Default_Org props, Just (Main.Layouts.Model.Default_Build existing) ) -> let route : Route () @@ -330,6 +464,22 @@ initLayout model layout = , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Repo repoLayoutEffect) ) + ( Layouts.Default_Repo props, Just (Main.Layouts.Model.Default_Admin existing) ) -> + let + route : Route () + route = + Route.fromUrl () model.url + + defaultRepoLayout = + Layouts.Default.Repo.layout props model.shared route + + ( repoLayoutModel, repoLayoutEffect ) = + Layout.init defaultRepoLayout () + in + ( Main.Layouts.Model.Default_Repo { default = existing.default, repo = repoLayoutModel } + , fromLayoutEffect model (Effect.map Main.Layouts.Msg.Default_Repo repoLayoutEffect) + ) + ( Layouts.Default_Repo props, Just (Main.Layouts.Model.Default_Build existing) ) -> let route : Route () @@ -528,6 +678,54 @@ initPageAndLayout model = } ) + Route.Path.Admin_Settings -> + runWhenAuthenticatedWithLayout + model + (\user -> + let + page : Page.Page Pages.Admin.Settings.Model Pages.Admin.Settings.Msg + page = + Pages.Admin.Settings.page user model.shared (Route.fromUrl () model.url) + + ( pageModel, pageEffect ) = + Page.init page () + in + { page = + Tuple.mapBoth + Main.Pages.Model.Admin_Settings + (Effect.map Main.Pages.Msg.Admin_Settings >> fromPageEffect model) + ( pageModel, pageEffect ) + , layout = + Page.layout pageModel page + |> Maybe.map (Layouts.map (Main.Pages.Msg.Admin_Settings >> Page)) + |> Maybe.map (initLayout model) + } + ) + + Route.Path.Admin_Workers -> + runWhenAuthenticatedWithLayout + model + (\user -> + let + page : Page.Page Pages.Admin.Workers.Model Pages.Admin.Workers.Msg + page = + Pages.Admin.Workers.page user model.shared (Route.fromUrl () model.url) + + ( pageModel, pageEffect ) = + Page.init page () + in + { page = + Tuple.mapBoth + Main.Pages.Model.Admin_Workers + (Effect.map Main.Pages.Msg.Admin_Workers >> fromPageEffect model) + ( pageModel, pageEffect ) + , layout = + Page.layout pageModel page + |> Maybe.map (Layouts.map (Main.Pages.Msg.Admin_Workers >> Page)) + |> Maybe.map (initLayout model) + } + ) + Route.Path.Dash_Secrets_Engine__Org_Org_ params -> runWhenAuthenticatedWithLayout model @@ -1412,6 +1610,26 @@ updateFromPage msg model = (Page.update (Pages.Account.SourceRepos.page user model.shared (Route.fromUrl () model.url)) pageMsg pageModel) ) + ( Main.Pages.Msg.Admin_Settings pageMsg, Main.Pages.Model.Admin_Settings pageModel ) -> + runWhenAuthenticated + model + (\user -> + Tuple.mapBoth + Main.Pages.Model.Admin_Settings + (Effect.map Main.Pages.Msg.Admin_Settings >> fromPageEffect model) + (Page.update (Pages.Admin.Settings.page user model.shared (Route.fromUrl () model.url)) pageMsg pageModel) + ) + + ( Main.Pages.Msg.Admin_Workers pageMsg, Main.Pages.Model.Admin_Workers pageModel ) -> + runWhenAuthenticated + model + (\user -> + Tuple.mapBoth + Main.Pages.Model.Admin_Workers + (Effect.map Main.Pages.Msg.Admin_Workers >> fromPageEffect model) + (Page.update (Pages.Admin.Workers.page user model.shared (Route.fromUrl () model.url)) pageMsg pageModel) + ) + ( Main.Pages.Msg.Dash_Secrets_Engine__Org_Org_ pageMsg, Main.Pages.Model.Dash_Secrets_Engine__Org_Org_ params pageModel ) -> runWhenAuthenticated model @@ -1680,6 +1898,23 @@ updateFromLayout msg model = (Effect.map Main.Layouts.Msg.Default >> fromLayoutEffect model) (Layout.update (Layouts.Default.layout props model.shared route) layoutMsg layoutModel.default) + ( Just (Layouts.Default_Admin props), Just (Main.Layouts.Model.Default_Admin layoutModel), Main.Layouts.Msg.Default layoutMsg ) -> + let + defaultProps = + Layouts.Default.Admin.layout props model.shared route + |> Layout.parentProps + in + Tuple.mapBoth + (\newModel -> Just (Main.Layouts.Model.Default_Admin { layoutModel | default = newModel })) + (Effect.map Main.Layouts.Msg.Default >> fromLayoutEffect model) + (Layout.update (Layouts.Default.layout defaultProps model.shared route) layoutMsg layoutModel.default) + + ( Just (Layouts.Default_Admin props), Just (Main.Layouts.Model.Default_Admin layoutModel), Main.Layouts.Msg.Default_Admin layoutMsg ) -> + Tuple.mapBoth + (\newModel -> Just (Main.Layouts.Model.Default_Admin { layoutModel | admin = newModel })) + (Effect.map Main.Layouts.Msg.Default_Admin >> fromLayoutEffect model) + (Layout.update (Layouts.Default.Admin.layout props model.shared route) layoutMsg layoutModel.admin) + ( Just (Layouts.Default_Build props), Just (Main.Layouts.Model.Default_Build layoutModel), Main.Layouts.Msg.Default layoutMsg ) -> let defaultProps = @@ -1776,6 +2011,18 @@ toLayoutFromPage model = |> Maybe.andThen (Page.layout pageModel) |> Maybe.map (Layouts.map (Main.Pages.Msg.Account_SourceRepos >> Page)) + Main.Pages.Model.Admin_Settings pageModel -> + Route.fromUrl () model.url + |> toAuthProtectedPage model Pages.Admin.Settings.page + |> Maybe.andThen (Page.layout pageModel) + |> Maybe.map (Layouts.map (Main.Pages.Msg.Admin_Settings >> Page)) + + Main.Pages.Model.Admin_Workers pageModel -> + Route.fromUrl () model.url + |> toAuthProtectedPage model Pages.Admin.Workers.page + |> Maybe.andThen (Page.layout pageModel) + |> Maybe.map (Layouts.map (Main.Pages.Msg.Admin_Workers >> Page)) + Main.Pages.Model.Dash_Secrets_Engine__Org_Org_ params pageModel -> Route.fromUrl params model.url |> toAuthProtectedPage model Pages.Dash.Secrets.Engine_.Org.Org_.page @@ -2019,6 +2266,24 @@ subscriptions model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Admin_Settings pageModel -> + Auth.Action.subscriptions + (\user -> + Page.subscriptions (Pages.Admin.Settings.page user model.shared (Route.fromUrl () model.url)) pageModel + |> Sub.map Main.Pages.Msg.Admin_Settings + |> Sub.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Admin_Workers pageModel -> + Auth.Action.subscriptions + (\user -> + Page.subscriptions (Pages.Admin.Workers.page user model.shared (Route.fromUrl () model.url)) pageModel + |> Sub.map Main.Pages.Msg.Admin_Workers + |> Sub.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dash_Secrets_Engine__Org_Org_ params pageModel -> Auth.Action.subscriptions (\user -> @@ -2263,6 +2528,21 @@ subscriptions model = |> Sub.map Main.Layouts.Msg.Default |> Sub.map Layout + ( Just (Layouts.Default_Admin props), Just (Main.Layouts.Model.Default_Admin layoutModel) ) -> + let + defaultProps = + Layouts.Default.Admin.layout props model.shared route + |> Layout.parentProps + in + Sub.batch + [ Layout.subscriptions (Layouts.Default.layout defaultProps model.shared route) layoutModel.default + |> Sub.map Main.Layouts.Msg.Default + |> Sub.map Layout + , Layout.subscriptions (Layouts.Default.Admin.layout props model.shared route) layoutModel.admin + |> Sub.map Main.Layouts.Msg.Default_Admin + |> Sub.map Layout + ] + ( Just (Layouts.Default_Build props), Just (Main.Layouts.Model.Default_Build layoutModel) ) -> let defaultProps = @@ -2353,6 +2633,25 @@ toView model = , content = viewPage model } + ( Just (Layouts.Default_Admin props), Just (Main.Layouts.Model.Default_Admin layoutModel) ) -> + let + defaultProps = + Layouts.Default.Admin.layout props model.shared route + |> Layout.parentProps + in + Layout.view + (Layouts.Default.layout defaultProps model.shared route) + { model = layoutModel.default + , toContentMsg = Main.Layouts.Msg.Default >> Layout + , content = + Layout.view + (Layouts.Default.Admin.layout props model.shared route) + { model = layoutModel.admin + , toContentMsg = Main.Layouts.Msg.Default_Admin >> Layout + , content = viewPage model + } + } + ( Just (Layouts.Default_Build props), Just (Main.Layouts.Model.Default_Build layoutModel) ) -> let defaultProps = @@ -2459,6 +2758,24 @@ viewPage model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Admin_Settings pageModel -> + Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) + (\user -> + Page.view (Pages.Admin.Settings.page user model.shared (Route.fromUrl () model.url)) pageModel + |> View.map Main.Pages.Msg.Admin_Settings + |> View.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Admin_Workers pageModel -> + Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) + (\user -> + Page.view (Pages.Admin.Workers.page user model.shared (Route.fromUrl () model.url)) pageModel + |> View.map Main.Pages.Msg.Admin_Workers + |> View.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dash_Secrets_Engine__Org_Org_ params pageModel -> Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) (\user -> @@ -2793,6 +3110,26 @@ toPageUrlHookCmd model routes = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Admin_Settings pageModel -> + Auth.Action.command + (\user -> + Page.toUrlMessages routes (Pages.Admin.Settings.page user model.shared (Route.fromUrl () model.url)) + |> List.map Main.Pages.Msg.Admin_Settings + |> List.map Page + |> toCommands + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Admin_Workers pageModel -> + Auth.Action.command + (\user -> + Page.toUrlMessages routes (Pages.Admin.Workers.page user model.shared (Route.fromUrl () model.url)) + |> List.map Main.Pages.Msg.Admin_Workers + |> List.map Page + |> toCommands + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dash_Secrets_Engine__Org_Org_ params pageModel -> Auth.Action.command (\user -> @@ -3076,6 +3413,23 @@ toLayoutUrlHookCmd oldModel model routes = |> List.map Layout |> toCommands + ( Just (Layouts.Default_Admin props), Just (Main.Layouts.Model.Default_Admin layoutModel) ) -> + let + defaultProps = + Layouts.Default.Admin.layout props model.shared route + |> Layout.parentProps + in + Cmd.batch + [ Layout.toUrlMessages routes (Layouts.Default.layout defaultProps model.shared route) + |> List.map Main.Layouts.Msg.Default + |> List.map Layout + |> toCommands + , Layout.toUrlMessages routes (Layouts.Default.Admin.layout props model.shared route) + |> List.map Main.Layouts.Msg.Default_Admin + |> List.map Layout + |> toCommands + ] + ( Just (Layouts.Default_Build props), Just (Main.Layouts.Model.Default_Build layoutModel) ) -> let defaultProps = @@ -3139,6 +3493,12 @@ hasNavigatedWithinNewLayout { from, to } = Just ( Layouts.Default _, Layouts.Default _ ) -> True + Just ( Layouts.Default_Admin _, Layouts.Default_Admin _ ) -> + True + + Just ( Layouts.Default_Admin _, Layouts.Default _ ) -> + True + Just ( Layouts.Default_Build _, Layouts.Default_Build _ ) -> True @@ -3187,6 +3547,12 @@ isAuthProtected routePath = Route.Path.Account_SourceRepos -> True + Route.Path.Admin_Settings -> + True + + Route.Path.Admin_Workers -> + True + Route.Path.Dash_Secrets_Engine__Org_Org_ _ -> True diff --git a/src/elm/Main/Layouts/Model.elm b/src/elm/Main/Layouts/Model.elm index 3b153d57d..a884e7ffd 100644 --- a/src/elm/Main/Layouts/Model.elm +++ b/src/elm/Main/Layouts/Model.elm @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 module Main.Layouts.Model exposing (..) import Layouts.Default +import Layouts.Default.Admin import Layouts.Default.Build import Layouts.Default.Org import Layouts.Default.Repo @@ -13,6 +14,7 @@ import Layouts.Default.Repo type Model = Default { default : Layouts.Default.Model } + | Default_Admin { default : Layouts.Default.Model, admin : Layouts.Default.Admin.Model } | Default_Build { default : Layouts.Default.Model, build : Layouts.Default.Build.Model } | Default_Org { default : Layouts.Default.Model, org : Layouts.Default.Org.Model } | Default_Repo { default : Layouts.Default.Model, repo : Layouts.Default.Repo.Model } diff --git a/src/elm/Main/Layouts/Msg.elm b/src/elm/Main/Layouts/Msg.elm index cd83173fe..7cb7681ac 100644 --- a/src/elm/Main/Layouts/Msg.elm +++ b/src/elm/Main/Layouts/Msg.elm @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 module Main.Layouts.Msg exposing (..) import Layouts.Default +import Layouts.Default.Admin import Layouts.Default.Build import Layouts.Default.Org import Layouts.Default.Repo @@ -13,6 +14,7 @@ import Layouts.Default.Repo type Msg = Default Layouts.Default.Msg + | Default_Admin Layouts.Default.Admin.Msg | Default_Build Layouts.Default.Build.Msg | Default_Org Layouts.Default.Org.Msg | Default_Repo Layouts.Default.Repo.Msg diff --git a/src/elm/Main/Pages/Model.elm b/src/elm/Main/Pages/Model.elm index 84eed9ef2..0bfe04309 100644 --- a/src/elm/Main/Pages/Model.elm +++ b/src/elm/Main/Pages/Model.elm @@ -10,6 +10,8 @@ import Pages.Account.Login import Pages.Account.Logout import Pages.Account.Settings import Pages.Account.SourceRepos +import Pages.Admin.Settings +import Pages.Admin.Workers import Pages.Dash.Secrets.Engine_.Org.Org_ import Pages.Dash.Secrets.Engine_.Org.Org_.Add import Pages.Dash.Secrets.Engine_.Org.Org_.Name_ @@ -47,6 +49,8 @@ type Model | Account_Logout Pages.Account.Logout.Model | Account_Settings Pages.Account.Settings.Model | Account_SourceRepos Pages.Account.SourceRepos.Model + | Admin_Settings Pages.Admin.Settings.Model + | Admin_Workers Pages.Admin.Workers.Model | Dash_Secrets_Engine__Org_Org_ { engine : String, org : String } Pages.Dash.Secrets.Engine_.Org.Org_.Model | Dash_Secrets_Engine__Org_Org__Add { engine : String, org : String } Pages.Dash.Secrets.Engine_.Org.Org_.Add.Model | Dash_Secrets_Engine__Org_Org__Name_ { engine : String, org : String, name : String } Pages.Dash.Secrets.Engine_.Org.Org_.Name_.Model diff --git a/src/elm/Main/Pages/Msg.elm b/src/elm/Main/Pages/Msg.elm index 88a25dfe0..cac5dae03 100644 --- a/src/elm/Main/Pages/Msg.elm +++ b/src/elm/Main/Pages/Msg.elm @@ -10,6 +10,8 @@ import Pages.Account.Login import Pages.Account.Logout import Pages.Account.Settings import Pages.Account.SourceRepos +import Pages.Admin.Settings +import Pages.Admin.Workers import Pages.Dash.Secrets.Engine_.Org.Org_ import Pages.Dash.Secrets.Engine_.Org.Org_.Add import Pages.Dash.Secrets.Engine_.Org.Org_.Name_ @@ -46,6 +48,8 @@ type Msg | Account_Logout Pages.Account.Logout.Msg | Account_Settings Pages.Account.Settings.Msg | Account_SourceRepos Pages.Account.SourceRepos.Msg + | Admin_Settings Pages.Admin.Settings.Msg + | Admin_Workers Pages.Admin.Workers.Msg | Dash_Secrets_Engine__Org_Org_ Pages.Dash.Secrets.Engine_.Org.Org_.Msg | Dash_Secrets_Engine__Org_Org__Add Pages.Dash.Secrets.Engine_.Org.Org_.Add.Msg | Dash_Secrets_Engine__Org_Org__Name_ Pages.Dash.Secrets.Engine_.Org.Org_.Name_.Msg diff --git a/src/elm/Pages/Account/Logout.elm b/src/elm/Pages/Account/Logout.elm index 88bf193b1..807a81818 100644 --- a/src/elm/Pages/Account/Logout.elm +++ b/src/elm/Pages/Account/Logout.elm @@ -27,10 +27,14 @@ page shared route = -- INIT +{-| Model : alias for a model object for the page. +-} type alias Model = {} +{-| init : initializes page with no arguments. +-} init : Route () -> () -> ( Model, Effect Msg ) init route () = ( {} @@ -42,6 +46,8 @@ init route () = -- UPDATE +{-| Msg : custom type with possible messages. +-} type Msg = NoOp diff --git a/src/elm/Pages/Account/SourceRepos.elm b/src/elm/Pages/Account/SourceRepos.elm index 7f8727251..7c42dd21e 100644 --- a/src/elm/Pages/Account/SourceRepos.elm +++ b/src/elm/Pages/Account/SourceRepos.elm @@ -103,7 +103,7 @@ init shared () = , sourceRepos = shared.sourceRepos } , Effect.batch - [ Effect.getCurrentUser {} + [ Effect.getCurrentUserShared {} , Effect.sendMsg (GetUserSourceRepos False) , Effect.focusOn { target = "global-search-input" } ] diff --git a/src/elm/Pages/Admin/Settings.elm b/src/elm/Pages/Admin/Settings.elm new file mode 100644 index 000000000..2b285c5c2 --- /dev/null +++ b/src/elm/Pages/Admin/Settings.elm @@ -0,0 +1,1274 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Admin.Settings exposing (Model, Msg, page) + +import Auth +import Components.Form +import Dict +import Effect exposing (Effect) +import Html exposing (Html, div, h2, i, p, section, span, strong, text) +import Html.Attributes exposing (class) +import Http +import Http.Detailed +import Json.Decode exposing (Error(..)) +import Layouts +import List.Extra +import Maybe.Extra +import Page exposing (Page) +import RemoteData exposing (WebData) +import Route exposing (Route) +import Shared +import Time +import Utils.Errors as Errors +import Utils.Helpers as Util +import Utils.Interval as Interval +import Vela exposing (defaultCompilerPayload, defaultQueuePayload, defaultSettingsPayload) +import View exposing (View) + + +{-| page : shared model, route, and returns the page. +-} +page : Auth.User -> Shared.Model -> Route () -> Page Model Msg +page user shared route = + Page.new + { init = init shared + , update = update shared route + , subscriptions = subscriptions + , view = view shared route + } + |> Page.withLayout (toLayout user) + + + +-- LAYOUT + + +{-| toLayout : takes model and passes the page info to Layouts. +-} +toLayout : Auth.User -> Model -> Layouts.Layout Msg +toLayout user model = + Layouts.Default_Admin + { navButtons = [] + , utilButtons = [] + , helpCommands = + [ { name = "View Settings" + , content = "vela view settings" + , docs = Just "cli/settings/view" + } + , { name = "Update Settings" + , content = "vela update settings" + , docs = Just "cli/settings/update" + } + ] + , crumbs = + [ ( "Admin", Nothing ) + ] + } + + + +-- INIT + + +{-| Model : alias for model for the page. +-} +type alias Model = + { settings : WebData Vela.PlatformSettings + , originalSettings : Maybe Vela.PlatformSettings + , exported : WebData String + , cloneImage : String + , starlarkExecLimitIn : String + , templateDepthIn : String + , queueRoutes : Components.Form.EditableListForm + , repoAllowlist : Components.Form.EditableListForm + , scheduleAllowlist : Components.Form.EditableListForm + } + + +{-| init : initializes page with no arguments. +-} +init : Shared.Model -> () -> ( Model, Effect Msg ) +init shared () = + ( { settings = RemoteData.Loading + , originalSettings = Nothing + , exported = RemoteData.Loading + , cloneImage = "" + , starlarkExecLimitIn = "" + , templateDepthIn = "" + , queueRoutes = { val = "", editing = Dict.empty } + , repoAllowlist = { val = "", editing = Dict.empty } + , scheduleAllowlist = { val = "", editing = Dict.empty } + } + , Effect.getSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetSettingsResponse + } + ) + + + +-- UPDATE + + +{-| Msg : custom type with possible messages. +-} +type Msg + = -- SETTINGS + GetSettingsResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.PlatformSettings )) + | RefreshSettingsResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.PlatformSettings )) + | UpdateSettingsResponse { field : Vela.PlatformSettingsFieldUpdate } (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.PlatformSettings )) + -- COMPILER + | CloneImageOnInput String + | CloneImageOnUpdate String + | StarlarkExecLimitOnInput String + | StarlarkExecLimitOnUpdate String + | TemplateDepthOnInput String + | TemplateDepthOnUpdate String + -- QUEUE + | QueueRoutesOnInput String + | QueueRoutesAddOnClick String + | QueueRoutesEditOnClick { id : String } + | QueueRoutesSaveOnClick { id : String, val : String } + | QueueRoutesEditOnInput { id : String } String + | QueueRoutesRemoveOnClick String + -- REPOS + | RepoAllowlistOnInput String + | RepoAllowlistAddOnClick String + | RepoAllowlistEditOnClick { id : String } + | RepoAllowlistSaveOnClick { id : String, val : String } + | RepoAllowlistEditOnInput { id : String } String + | RepoAllowlistRemoveOnClick String + -- SCHEDULES + | ScheduleAllowlistOnInput String + | ScheduleAllowlistAddOnClick String + | ScheduleAllowlistEditOnClick { id : String } + | ScheduleAllowlistSaveOnClick { id : String, val : String } + | ScheduleAllowlistEditOnInput { id : String } String + | ScheduleAllowlistRemoveOnClick String + -- REFRESH + | Tick { time : Time.Posix, interval : Interval.Interval } + + +{-| update : takes current models, message, and returns an updated model and effect. +-} +update : Shared.Model -> Route () -> Msg -> Model -> ( Model, Effect Msg ) +update shared route msg model = + case msg of + GetSettingsResponse response -> + case response of + Ok ( meta, settings ) -> + ( { model + | originalSettings = Just settings + , settings = RemoteData.Success settings + , cloneImage = settings.compiler.cloneImage + , starlarkExecLimitIn = String.fromInt settings.compiler.starlarkExecLimit + , templateDepthIn = String.fromInt settings.compiler.templateDepth + } + , Effect.none + ) + + Err error -> + ( { model | settings = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + RefreshSettingsResponse response -> + case response of + Ok ( meta, settings ) -> + ( { model + | settings = + case model.settings of + RemoteData.Success s -> + if settings.updatedAt < s.updatedAt then + model.settings + + else + RemoteData.Success settings + + _ -> + RemoteData.Success settings + } + , Effect.none + ) + + Err error -> + ( { model | settings = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + UpdateSettingsResponse options response -> + case response of + Ok ( meta, settings ) -> + let + responseConfig = + Vela.platformSettingsFieldUpdateToResponseConfig options.field + in + ( { model + | settings = RemoteData.Success settings + } + , Effect.addAlertSuccess + { content = responseConfig.successAlert settings + , addToastIfUnique = False + , link = Nothing + } + ) + + Err error -> + ( model + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + -- COMPILER + CloneImageOnInput val -> + ( { model + | cloneImage = val + } + , Effect.none + ) + + CloneImageOnUpdate val -> + let + compilerPayload = + { defaultCompilerPayload + | cloneImage = Just val + } + + payload = + { defaultSettingsPayload + | compiler = Just compilerPayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + ( { model + | cloneImage = val + } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.CompilerCloneImage + } + , body = body + } + ) + + StarlarkExecLimitOnInput val -> + ( { model + | starlarkExecLimitIn = val + } + , Effect.none + ) + + StarlarkExecLimitOnUpdate val -> + let + compilerPayload = + { defaultCompilerPayload + | starlarkExecLimit = String.toInt val + } + + payload = + { defaultSettingsPayload + | compiler = Just compilerPayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + ( { model + | starlarkExecLimitIn = val + } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.CompilerStarlarkExecLimit + } + , body = body + } + ) + + TemplateDepthOnInput val -> + ( { model + | templateDepthIn = Components.Form.handleNumberInputString model.templateDepthIn val + } + , Effect.none + ) + + TemplateDepthOnUpdate val -> + let + compilerPayload = + { defaultCompilerPayload + | templateDepth = String.toInt val + } + + payload = + { defaultSettingsPayload + | compiler = Just compilerPayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + ( { model + | templateDepthIn = val + } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.CompilerTemplateDepth + } + , body = body + } + ) + + -- QUEUE + QueueRoutesOnInput val -> + let + editableListForm = + model.queueRoutes + in + ( { model + | queueRoutes = { editableListForm | val = val } + } + , Effect.none + ) + + QueueRoutesAddOnClick val -> + let + currentRoutes = + RemoteData.unwrap [] (.queue >> .routes) model.settings + + effect = + if not <| List.member val currentRoutes then + let + queuePayload = + { defaultQueuePayload + | routes = Just <| List.Extra.unique <| val :: currentRoutes + } + + payload = + { defaultSettingsPayload + | queue = Just queuePayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.QueueRouteAdd val + } + , body = body + } + + else + Effect.addAlertSuccess + { content = "Queue route '" ++ val ++ "' already exists." + , addToastIfUnique = False + , link = Nothing + } + + editableListForm = + model.queueRoutes + in + ( { model | queueRoutes = { editableListForm | val = "" } } + , effect + ) + + QueueRoutesRemoveOnClick val -> + let + queuePayload = + { defaultQueuePayload + | routes = Just <| List.Extra.remove val <| RemoteData.unwrap [] (.queue >> .routes) model.settings + } + + payload = + { defaultSettingsPayload + | queue = Just queuePayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + + editableListForm = + model.queueRoutes + in + ( { model | queueRoutes = { editableListForm | editing = Dict.remove val editableListForm.editing } } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.QueueRouteRemove val + } + , body = body + } + ) + + QueueRoutesEditOnClick options -> + let + queueRoutes = + model.queueRoutes + in + ( { model + | queueRoutes = + { queueRoutes + | editing = Dict.insert options.id options.id model.queueRoutes.editing + } + } + , Effect.focusOn { target = saveButtonHtmlId queueRoutesHtmlId options.id } + ) + + QueueRoutesSaveOnClick options -> + let + effect = + if options.id /= options.val && String.length options.val > 0 then + let + queuePayload = + { defaultQueuePayload + | routes = + model.settings + |> RemoteData.unwrap [] (.queue >> .routes) + |> List.Extra.updateIf (\item -> item == options.id) (\_ -> options.val) + |> Just + } + + payload = + { defaultSettingsPayload + | queue = Just queuePayload + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.QueueRouteUpdate options.id options.val + } + , body = body + } + + else + Effect.none + + editableListForm = + model.queueRoutes + in + ( { model + | queueRoutes = + { editableListForm + | editing = Dict.remove options.id model.queueRoutes.editing + } + } + , effect + ) + + QueueRoutesEditOnInput options val -> + let + editableListForm = + model.queueRoutes + in + ( { model + | queueRoutes = + { editableListForm + | editing = Dict.insert options.id val model.queueRoutes.editing + } + } + , Effect.none + ) + + -- REPOS + RepoAllowlistOnInput val -> + let + editableListForm = + model.repoAllowlist + in + ( { model + | repoAllowlist = { editableListForm | val = val } + } + , Effect.none + ) + + RepoAllowlistAddOnClick val -> + let + currentRepos = + RemoteData.unwrap [] .repoAllowlist model.settings + + effect = + if not <| List.member val currentRepos then + let + payload = + { defaultSettingsPayload + | repoAllowlist = Just <| List.Extra.unique <| val :: currentRepos + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.RepoAllowlistAdd val + } + , body = body + } + + else + Effect.addAlertSuccess + { content = "Repo '" ++ val ++ "' already exists in overall allowlist." + , addToastIfUnique = False + , link = Nothing + } + + editableListForm = + model.repoAllowlist + in + ( { model | repoAllowlist = { editableListForm | val = "" } } + , effect + ) + + RepoAllowlistRemoveOnClick val -> + let + payload = + { defaultSettingsPayload + | repoAllowlist = Just <| List.Extra.remove val <| RemoteData.unwrap [] .repoAllowlist model.settings + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + + editableListForm = + model.repoAllowlist + in + ( { model | repoAllowlist = { editableListForm | editing = Dict.remove val editableListForm.editing } } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.RepoAllowlistRemove val + } + , body = body + } + ) + + RepoAllowlistEditOnClick options -> + let + editableListForm = + model.repoAllowlist + in + ( { model + | repoAllowlist = + { editableListForm + | editing = Dict.insert options.id options.id model.repoAllowlist.editing + } + } + , Effect.focusOn { target = saveButtonHtmlId repoAllowlistHtmlId options.id } + ) + + RepoAllowlistSaveOnClick options -> + let + effect = + if options.id /= options.val && String.length options.val > 0 then + let + payload = + { defaultSettingsPayload + | repoAllowlist = + model.settings + |> RemoteData.unwrap [] .repoAllowlist + |> List.Extra.updateIf (\item -> item == options.id) (\_ -> options.val) + |> Just + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.RepoAllowlistUpdate options.id options.val + } + , body = body + } + + else + Effect.none + + editableListForm = + model.repoAllowlist + in + ( { model + | repoAllowlist = + { editableListForm + | editing = Dict.remove options.id model.repoAllowlist.editing + } + } + , effect + ) + + RepoAllowlistEditOnInput options val -> + let + editableListForm = + model.repoAllowlist + in + ( { model + | repoAllowlist = + { editableListForm + | editing = Dict.insert options.id val model.repoAllowlist.editing + } + } + , Effect.none + ) + + -- SCHEDULES + ScheduleAllowlistOnInput val -> + let + editableListForm = + model.scheduleAllowlist + in + ( { model + | scheduleAllowlist = { editableListForm | val = val } + } + , Effect.none + ) + + ScheduleAllowlistAddOnClick val -> + let + currentRepos = + RemoteData.unwrap [] .scheduleAllowlist model.settings + + effect = + if not <| List.member val currentRepos then + let + payload = + { defaultSettingsPayload + | scheduleAllowlist = Just <| List.Extra.unique <| val :: RemoteData.unwrap [] .scheduleAllowlist model.settings + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.ScheduleAllowlistAdd val + } + , body = body + } + + else + Effect.addAlertSuccess + { content = "Repo '" ++ val ++ "' already exists in schedule allowlist." + , addToastIfUnique = False + , link = Nothing + } + + editableListForm = + model.scheduleAllowlist + in + ( { model | scheduleAllowlist = { editableListForm | val = "" } } + , effect + ) + + ScheduleAllowlistRemoveOnClick val -> + let + payload = + { defaultSettingsPayload + | scheduleAllowlist = Just <| List.Extra.remove val <| RemoteData.unwrap [] .scheduleAllowlist model.settings + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + + editableListForm = + model.scheduleAllowlist + in + ( { model | scheduleAllowlist = { editableListForm | editing = Dict.remove val editableListForm.editing } } + , Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.ScheduleAllowlistRemove val + } + , body = body + } + ) + + ScheduleAllowlistEditOnClick options -> + let + editableListForm = + model.scheduleAllowlist + in + ( { model + | scheduleAllowlist = + { editableListForm + | editing = Dict.insert options.id options.id model.scheduleAllowlist.editing + } + } + , Effect.focusOn { target = saveButtonHtmlId scheduleAllowlistHtmlId options.id } + ) + + ScheduleAllowlistSaveOnClick options -> + let + effect = + if options.id /= options.val && String.length options.val > 0 then + let + payload = + { defaultSettingsPayload + | scheduleAllowlist = + model.settings + |> RemoteData.unwrap [] .scheduleAllowlist + |> List.Extra.updateIf (\item -> item == options.id) (\_ -> options.val) + |> Just + } + + body = + Http.jsonBody <| Vela.encodeSettingsPayload payload + in + Effect.updateSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = + UpdateSettingsResponse + { field = Vela.ScheduleAllowlistUpdate options.id options.val + } + , body = body + } + + else + Effect.none + + editableListForm = + model.scheduleAllowlist + in + ( { model + | scheduleAllowlist = + { editableListForm + | editing = Dict.remove options.id model.scheduleAllowlist.editing + } + } + , effect + ) + + ScheduleAllowlistEditOnInput options val -> + let + editableListForm = + model.scheduleAllowlist + in + ( { model + | scheduleAllowlist = + { editableListForm + | editing = Dict.insert options.id val model.scheduleAllowlist.editing + } + } + , Effect.none + ) + + -- REFRESH + Tick options -> + ( model + , Effect.getSettings + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = RefreshSettingsResponse + } + ) + + + +-- SUBSCRIPTIONS + + +{-| subscriptions : takes model and returns subscriptions. +-} +subscriptions : Model -> Sub Msg +subscriptions model = + Interval.tickEveryFiveSeconds Tick + + + +-- VIEW + + +{-| view : takes models, route, and creates the html for the page. +-} +view : Shared.Model -> Route () -> Model -> View Msg +view shared route model = + { title = "" + , body = + [ div [ class "admin-settings" ] + [ section + [ class "settings" + ] + [ viewFieldHeader "Clone Image" + , viewFieldDescription "The image to use with the embedded clone step." + , viewFieldEnvKeyValue "VELA_CLONE_IMAGE" + , div [ class "form-controls" ] + [ Components.Form.viewInput + { title = Nothing + , subtitle = Nothing + , id_ = cloneImageHtmlId + , val = model.cloneImage + , placeholder_ = "docker.io/target/vela-git:latest" + , classList_ = [] + , wrapperClassList = [ ( "-wide", True ) ] + , rows_ = Nothing + , wrap_ = Nothing + , msg = CloneImageOnInput + , disabled_ = False + } + , Components.Form.viewButton + { id_ = cloneImageHtmlId ++ "-update" + , msg = CloneImageOnUpdate model.cloneImage + , text_ = "update" + , classList_ = + [ ( "-outline", True ) + ] + , disabled_ = + RemoteData.unwrap True + (\s -> + String.isEmpty model.cloneImage + || s.compiler.cloneImage + == model.cloneImage + ) + model.settings + } + ] + , viewFieldPreviousValue model + (\s -> s.compiler.cloneImage) + (\ms -> Maybe.Extra.unwrap "" (.compiler >> .cloneImage) ms) + ] + , section + [ class "settings" + ] + [ viewFieldHeader "Starlark Exec Limit" + , viewFieldDescription "The number of executions allowed for Starlark scripts." + , viewFieldEnvKeyValue "VELA_COMPILER_STARLARK_EXEC_LIMIT" + , viewFieldLimits <| text <| numberBoundsToString starlarkExecLimitMin starlarkExecLimitMax + , div [ class "form-controls" ] + [ Components.Form.viewNumberInput + { title = Nothing + , subtitle = Nothing + , id_ = starlarkExecLimitHtmlId + , val = model.starlarkExecLimitIn + , placeholder_ = numberBoundsToString starlarkExecLimitMin starlarkExecLimitMax + , wrapperClassList = [ ( "-wide", True ) ] + , classList_ = [] + , rows_ = Nothing + , wrap_ = Nothing + , msg = StarlarkExecLimitOnInput + , disabled_ = False + , min = Just starlarkExecLimitMin + , max = Just starlarkExecLimitMax + } + , Components.Form.viewButton + { id_ = starlarkExecLimitHtmlId ++ "-update" + , msg = StarlarkExecLimitOnUpdate model.starlarkExecLimitIn + , text_ = "update" + , classList_ = + [ ( "-outline", True ) + ] + , disabled_ = + RemoteData.unwrap True + (\s -> + case String.toInt model.starlarkExecLimitIn of + Just limit -> + limit + == s.compiler.starlarkExecLimit + || (limit < starlarkExecLimitMin) + || (limit > starlarkExecLimitMax) + + Nothing -> + True + ) + model.settings + } + ] + , viewFieldPreviousValue model + (\s -> String.fromInt s.compiler.starlarkExecLimit) + (\ms -> Maybe.Extra.unwrap "" (.compiler >> .starlarkExecLimit >> String.fromInt) ms) + ] + , section + [ class "settings" + ] + [ viewFieldHeader "Template Depth" + , viewFieldDescription "The depth allowed for nested template references." + , viewFieldEnvKeyValue "VELA_TEMPLATE_DEPTH" + , viewFieldLimits <| text <| numberBoundsToString templateDepthLimitMin templateDepthLimitMax + , div [ class "form-controls" ] + [ Components.Form.viewNumberInput + { title = Nothing + , subtitle = Nothing + , id_ = "template-depth" + , val = model.templateDepthIn + , placeholder_ = numberBoundsToString templateDepthLimitMin templateDepthLimitMax + , wrapperClassList = [ ( "-wide", True ) ] + , classList_ = [] + , rows_ = Nothing + , wrap_ = Nothing + , msg = TemplateDepthOnInput + , disabled_ = False + , min = Just templateDepthLimitMin + , max = Just templateDepthLimitMax + } + , Components.Form.viewButton + { id_ = "template-depth-update" + , msg = TemplateDepthOnUpdate model.templateDepthIn + , text_ = "update" + , classList_ = + [ ( "-outline", True ) + ] + , disabled_ = + RemoteData.unwrap True + (\s -> + case String.toInt model.templateDepthIn of + Just limit -> + limit + == s.compiler.templateDepth + || (limit < templateDepthLimitMin) + || (limit > templateDepthLimitMax) + + Nothing -> + True + ) + model.settings + } + ] + , viewFieldPreviousValue model + (\s -> String.fromInt s.compiler.templateDepth) + (\ms -> Maybe.Extra.unwrap "" (.compiler >> .templateDepth >> String.fromInt) ms) + ] + , section + [ class "settings" + ] + [ viewFieldHeader "Queue Routes" + , viewFieldDescription "The queue routes used when queuing builds." + , viewFieldEnvKeyValue "VELA_QUEUE_ROUTES" + , Components.Form.viewEditableList + { id_ = queueRoutesHtmlId + , webdata = model.settings + , toItems = .queue >> .routes + , toId = identity + , toLabel = identity + , addProps = + Just + { placeholder_ = "vela" + , addOnInputMsg = QueueRoutesOnInput + , addOnClickMsg = QueueRoutesAddOnClick + } + , viewHttpError = + \error -> + span [ Util.testAttribute <| queueRoutesHtmlId ++ "-error" ] + [ text <| + case error of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "No settings found" + + _ -> + "No settings found, there was an error with the server (" ++ String.fromInt statusCode ++ ")" + + _ -> + "No settings found" + ] + , viewNoItems = text "No routes set" + , form = model.queueRoutes + , itemEditOnClickMsg = QueueRoutesEditOnClick + , itemSaveOnClickMsg = QueueRoutesSaveOnClick + , itemEditOnInputMsg = QueueRoutesEditOnInput + , itemRemoveOnClickMsg = QueueRoutesRemoveOnClick + } + ] + , section + [ class "settings" + ] + [ viewFieldHeader "Repo Allowlist" + , viewFieldDescription "The repos permitted to use Vela." + , viewFieldEnvKeyValue "VELA_REPO_ALLOWLIST" + , Components.Form.viewEditableList + { id_ = repoAllowlistHtmlId + , webdata = model.settings + , toItems = .repoAllowlist + , toId = \r -> r + , toLabel = + \r -> + if r == "*" then + r ++ " (all repos)" + + else + r + , addProps = + Just + { placeholder_ = "octocat/hello-world" + , addOnInputMsg = RepoAllowlistOnInput + , addOnClickMsg = RepoAllowlistAddOnClick + } + , viewHttpError = + \error -> + span [ Util.testAttribute <| repoAllowlistHtmlId ++ "-error" ] + [ text <| + case error of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "No settings found" + + _ -> + "No settings found, there was an error with the server (" ++ String.fromInt statusCode ++ ")" + + _ -> + "No settings found" + ] + , viewNoItems = text "No repos allowed" + , form = model.repoAllowlist + , itemEditOnClickMsg = RepoAllowlistEditOnClick + , itemSaveOnClickMsg = RepoAllowlistSaveOnClick + , itemEditOnInputMsg = RepoAllowlistEditOnInput + , itemRemoveOnClickMsg = RepoAllowlistRemoveOnClick + } + ] + , section + [ class "settings" + ] + [ viewFieldHeader "Schedule Allowlist" + , viewFieldDescription "The repos permitted to use schedules." + , viewFieldEnvKeyValue "VELA_SCHEDULE_ALLOWLIST" + , Components.Form.viewEditableList + { id_ = scheduleAllowlistHtmlId + , webdata = model.settings + , toItems = .scheduleAllowlist + , toId = \r -> r + , toLabel = + \r -> + if r == "*" then + r ++ " (all repos)" + + else + r + , addProps = + Just + { placeholder_ = "octocat/hello-world" + , addOnInputMsg = ScheduleAllowlistOnInput + , addOnClickMsg = ScheduleAllowlistAddOnClick + } + , viewHttpError = + \error -> + span [ Util.testAttribute <| scheduleAllowlistHtmlId ++ "-error" ] + [ text <| + case error of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "No settings found" + + _ -> + "No settings found, there was an error with the server (" ++ String.fromInt statusCode ++ ")" + + _ -> + "No settings found" + ] + , viewNoItems = text "No repos allowed" + , form = model.scheduleAllowlist + , itemEditOnClickMsg = ScheduleAllowlistEditOnClick + , itemSaveOnClickMsg = ScheduleAllowlistSaveOnClick + , itemEditOnInputMsg = ScheduleAllowlistEditOnInput + , itemRemoveOnClickMsg = ScheduleAllowlistRemoveOnClick + } + ] + ] + , case model.settings of + RemoteData.Success settings -> + if settings.updatedAt > 0 then + let + updatedAt = + Util.humanReadableDateTimeWithDefault shared.zone settings.updatedAt + in + p [] + [ text <| "Last updated on " + , i [] [ text updatedAt ] + , text " by " + , i [] [ text settings.updatedBy ] + , text "." + ] + + else + text "" + + _ -> + text "" + ] + } + + +{-| viewFieldHeader : renders header view for a settings field +-} +viewFieldHeader : String -> Html Msg +viewFieldHeader title = + h2 [ class "settings-title" ] + [ text title ] + + +{-| viewFieldDescription : renders description view for a settings field +-} +viewFieldDescription : String -> Html Msg +viewFieldDescription description = + p [ class "settings-description" ] + [ text description + ] + + +{-| viewFieldEnvKeyValue : renders env key view for a settings field +-} +viewFieldEnvKeyValue : String -> Html Msg +viewFieldEnvKeyValue envKey = + p [ class "settings-info" ] + [ strong [] [ text "Env: " ] + , span [] [ text envKey ] + ] + + +{-| viewFieldPreviousValue : renders previous value for a settings field +-} +viewFieldPreviousValue : Model -> (Vela.PlatformSettings -> String) -> (Maybe Vela.PlatformSettings -> String) -> Html Msg +viewFieldPreviousValue model toCurr toPrev = + p [ class "settings-previous-value" ] <| + case model.settings of + RemoteData.Success settings -> + if toPrev model.originalSettings /= toCurr settings then + [ strong [] [ text "Before: " ] + , span [] [ text (toPrev model.originalSettings) ] + ] + + else + [ text "" ] + + _ -> + [ text "" ] + + +{-| viewFieldLimits : renders limits or restrictions for a settings field +-} +viewFieldLimits : Html Msg -> Html Msg +viewFieldLimits viewLimits = + p [ class "settings-info" ] + [ strong [] [ text "Restrictions: " ] + , span [] [ viewLimits ] + ] + + +{-| numberBoundsToString : converts number bounds for a settings field to string +-} +numberBoundsToString : Int -> Int -> String +numberBoundsToString min max = + String.fromInt min ++ " <= value <= " ++ String.fromInt max + + + +-- HTML IDENTIFIERS + + +{-| cloneImageHtmlId : returns reusable id for clone image +-} +cloneImageHtmlId : String +cloneImageHtmlId = + "clone-image" + + +{-| starlarkExecLimitHtmlId : returns reusable id for starlark exec limit +-} +starlarkExecLimitHtmlId : String +starlarkExecLimitHtmlId = + "starlark-exec-limit" + + +{-| queueRoutesHtmlId : returns reusable id for queue routes +-} +queueRoutesHtmlId : String +queueRoutesHtmlId = + "queue-routes" + + +{-| repoAllowlistHtmlId : returns reusable id for repo allowlist +-} +repoAllowlistHtmlId : String +repoAllowlistHtmlId = + "repo-allowlist" + + +{-| scheduleAllowlistHtmlId : returns reusable id for schedule allowlist +-} +scheduleAllowlistHtmlId : String +scheduleAllowlistHtmlId = + "schedule-allowlist" + + +{-| saveButtonHtmlId : returns reusable id for save button +-} +saveButtonHtmlId : String -> String -> String +saveButtonHtmlId base id = + base ++ "-save-" ++ id + + + +-- LIMITS + + +{-| templateDepthLimitMin : returns the minimum value for the template depth limit +-} +templateDepthLimitMin : Int +templateDepthLimitMin = + 1 + + +{-| templateDepthLimitMax : returns the maximum value for the template depth limit +-} +templateDepthLimitMax : Int +templateDepthLimitMax = + 100 + + +{-| starlarkExecLimitMin : returns the minimum value for the starlark exec limit +-} +starlarkExecLimitMin : Int +starlarkExecLimitMin = + 1 + + +{-| starlarkExecLimitMax : returns the maximum value for the starlark exec limit +-} +starlarkExecLimitMax : Int +starlarkExecLimitMax = + 9999 diff --git a/src/elm/Pages/Admin/Workers.elm b/src/elm/Pages/Admin/Workers.elm new file mode 100644 index 000000000..9c8d88472 --- /dev/null +++ b/src/elm/Pages/Admin/Workers.elm @@ -0,0 +1,430 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Admin.Workers exposing (Model, Msg, page) + +import Api.Pagination +import Auth +import Components.Loading +import Components.Pager +import Components.Table +import Dict +import Effect exposing (Effect) +import Html + exposing + ( Html + , a + , div + , span + , text + , tr + ) +import Html.Attributes + exposing + ( class + , href + ) +import Http +import Http.Detailed +import Layouts +import LinkHeader exposing (WebLink) +import Page exposing (Page) +import RemoteData exposing (WebData) +import Route exposing (Route) +import Shared +import Time +import Utils.Errors as Errors +import Utils.Helpers as Util +import Utils.Interval as Interval +import Vela +import View exposing (View) + + +{-| page : shared model, route, and returns the page. +-} +page : Auth.User -> Shared.Model -> Route () -> Page Model Msg +page user shared route = + Page.new + { init = init shared route + , update = update shared route + , subscriptions = subscriptions + , view = view shared route + } + |> Page.withLayout (toLayout user) + + + +-- LAYOUT + + +{-| toLayout : takes model and passes the page info to Layouts. +-} +toLayout : Auth.User -> Model -> Layouts.Layout Msg +toLayout user model = + Layouts.Default_Admin + { navButtons = [] + , utilButtons = [] + , helpCommands = + [ { name = "List Workers" + , content = "vela get workers" + , docs = Just "cli/worker/get" + } + ] + , crumbs = + [ ( "Admin", Nothing ) + ] + } + + + +-- INIT + + +{-| Model : alias for model for the page. +-} +type alias Model = + { workers : WebData (List Vela.Worker) + , pager : List WebLink + } + + +{-| init : initializes page with no arguments. +-} +init : Shared.Model -> Route () -> () -> ( Model, Effect Msg ) +init shared route () = + ( { workers = RemoteData.Loading + , pager = [] + } + , Effect.getWorkers + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetWorkersResponse + , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt + , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt + } + ) + + + +-- UPDATE + + +{-| Msg : custom type with possible messages. +-} +type Msg + = -- WORKERS + GetWorkersResponse (Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Worker )) + | GotoPage Int + -- REFRESH + | Tick { time : Time.Posix, interval : Interval.Interval } + + +{-| update : takes current models, message, and returns an updated model and effect. +-} +update : Shared.Model -> Route () -> Msg -> Model -> ( Model, Effect Msg ) +update shared route msg model = + case msg of + -- WORKERS + GetWorkersResponse response -> + case response of + Ok ( meta, workers ) -> + ( { model + | workers = RemoteData.Success workers + , pager = Api.Pagination.get meta.headers + } + , Effect.none + ) + + Err error -> + ( { model | workers = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + GotoPage pageNumber -> + ( model + , Effect.batch + [ Effect.replaceRoute + { path = route.path + , query = + Dict.update "page" (\_ -> Just <| String.fromInt pageNumber) route.query + , hash = route.hash + } + , Effect.getWorkers + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetWorkersResponse + , pageNumber = Just pageNumber + , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt + } + ] + ) + + -- REFRESH + Tick options -> + ( model + , Effect.getWorkers + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetWorkersResponse + , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt + , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt + } + ) + + + +-- SUBSCRIPTIONS + + +{-| subscriptions : takes model and returns subscriptions. +-} +subscriptions : Model -> Sub Msg +subscriptions model = + Interval.tickEveryFiveSeconds Tick + + + +-- VIEW + + +{-| view : takes models, route, and creates the html for the page. +-} +view : Shared.Model -> Route () -> Model -> View Msg +view shared route model = + { title = "" + , body = + [ viewWorkers shared model route + , Components.Pager.view + { show = True + , links = model.pager + , labels = Components.Pager.defaultLabels + , msg = GotoPage + } + ] + } + + +{-| viewWorkers : renders a list of workers. +-} +viewWorkers : Shared.Model -> Model -> Route () -> Html Msg +viewWorkers shared model route = + let + actions = + Just <| + div [ class "buttons" ] + [ Components.Pager.view + { show = True + , links = model.pager + , labels = Components.Pager.defaultLabels + , msg = GotoPage + } + ] + + ( noRowsView, rows ) = + let + viewHttpError e = + span [ Util.testAttribute "workers-error" ] + [ text <| + case e of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "No workers found, most likely due to not having access to the resource" + + _ -> + "No workers found, there was an error with the server (" ++ String.fromInt statusCode ++ ")" + + _ -> + "No workers found" + ] + in + case model.workers of + RemoteData.Success w -> + ( text "No workers found" + , workersToRows shared w + ) + + RemoteData.Failure error -> + ( viewHttpError error, [] ) + + _ -> + ( Components.Loading.viewSmallLoader, [] ) + + cfg = + Components.Table.Config + "Workers" + "workers" + noRowsView + tableHeaders + rows + actions + in + div [] + [ Components.Table.view cfg + ] + + + +-- TABLE + + +{-| workersToRows : takes list of workers and produces list of Table rows +-} +workersToRows : Shared.Model -> List Vela.Worker -> Components.Table.Rows Vela.Worker Msg +workersToRows shared workers = + List.map (\worker -> Components.Table.Row worker (viewWorker shared)) workers + + +{-| tableHeaders : returns table headers for workers table +-} +tableHeaders : Components.Table.Columns +tableHeaders = + [ ( Nothing, "address" ) + , ( Nothing, "status" ) + , ( Nothing, "routes" ) + , ( Nothing, "active" ) + , ( Nothing, "last status update" ) + , ( Nothing, "running builds" ) + , ( Nothing, "last build started" ) + , ( Nothing, "last build finished" ) + , ( Nothing, "last checked in" ) + , ( Nothing, "build limit" ) + ] + + +{-| viewWorker : takes worker and renders a table row +-} +viewWorker : Shared.Model -> Vela.Worker -> Html Msg +viewWorker shared worker = + tr [ Util.testAttribute <| "workers-row", statusToRowClass worker.status ] + [ Components.Table.viewItemCell + { dataLabel = "address" + , parentClassList = [] + , itemClassList = [] + , children = + [ text worker.address + ] + } + , Components.Table.viewItemCell + { dataLabel = "status" + , parentClassList = [] + , itemClassList = [] + , children = + [ text worker.status + ] + } + , Components.Table.viewListItemCell + { dataLabel = "routes" + , parentClassList = [ ( "routes", True ) ] + , itemWrapperClassList = [] + , itemClassList = [] + , children = + [ text <| String.join ", " worker.routes + ] + } + , Components.Table.viewItemCell + { dataLabel = "active" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| Util.boolToYesNo worker.active + ] + } + , Components.Table.viewItemCell + { dataLabel = "last status update" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| Util.humanReadableDateTimeWithDefault shared.zone worker.last_status_update + ] + } + , Components.Table.viewItemCell + { dataLabel = "running builds" + , parentClassList = [] + , itemClassList = [] + , children = + [ viewWorkerBuildsLinks worker + ] + } + , Components.Table.viewItemCell + { dataLabel = "last build started" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| Util.humanReadableDateTimeWithDefault shared.zone worker.last_build_started + ] + } + , Components.Table.viewItemCell + { dataLabel = "last build finished" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| Util.humanReadableDateTimeWithDefault shared.zone worker.last_build_finished + ] + } + , Components.Table.viewItemCell + { dataLabel = "last checked in" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| Util.humanReadableDateTimeWithDefault shared.zone worker.last_checked_in + ] + } + , Components.Table.viewItemCell + { dataLabel = "build limit" + , parentClassList = [] + , itemClassList = [] + , children = + [ text <| String.fromInt worker.build_limit + ] + } + ] + + +{-| viewWorkerBuildsLinks : renders a list of links to worker builds +-} +viewWorkerBuildsLinks : Vela.Worker -> Html msg +viewWorkerBuildsLinks worker = + worker.running_builds + |> List.map + (\build -> + a + [ href build.link ] + [ text + (build.link + |> String.split "/" + |> List.reverse + |> List.head + |> Maybe.withDefault "" + |> String.append "#" + ) + ] + ) + |> List.intersperse (text ", ") + |> div [] + + +{-| statusToRowClass : takes a worker status and returns a class for the row +-} +statusToRowClass : String -> Html.Attribute msg +statusToRowClass status = + case status of + "idle" -> + class "status-idle" + + "available" -> + class "status-available" + + "busy" -> + class "status-busy" + + "error" -> + class "status-error" + + _ -> + class "status-success" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Add.elm b/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Add.elm index c1195a125..637510f15 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Add.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Add.elm @@ -275,7 +275,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "add org secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -287,7 +287,7 @@ view shared route model = , msg = NameOnInput , disabled_ = False } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Name_.elm b/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Name_.elm index de609c3f1..c4c9ae28a 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Name_.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Org/Org_/Name_.elm @@ -379,7 +379,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "edit org secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -391,7 +391,7 @@ view shared route model = , msg = NameOnInput , disabled_ = True } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Add.elm b/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Add.elm index 4db608c81..9483ec2f7 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Add.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Add.elm @@ -263,7 +263,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "add repo secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -275,7 +275,7 @@ view shared route model = , msg = NameOnInput , disabled_ = False } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Name_.elm b/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Name_.elm index 14017e41b..f2569686f 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Name_.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Repo/Org_/Repo_/Name_.elm @@ -372,7 +372,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "edit repo secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -384,7 +384,7 @@ view shared route model = , msg = \_ -> NoOp , disabled_ = True } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Add.elm b/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Add.elm index 0ad4fce9b..4e1ab64dc 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Add.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Add.elm @@ -283,7 +283,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "add shared secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Team" , subtitle = Nothing , id_ = "team" @@ -295,7 +295,7 @@ view shared route model = , msg = TeamOnInput , disabled_ = False } - , Components.Form.viewInput + , Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -307,7 +307,7 @@ view shared route model = , msg = NameOnInput , disabled_ = False } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Name_.elm b/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Name_.elm index d8b1cfa63..99b4982ab 100644 --- a/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Name_.elm +++ b/src/elm/Pages/Dash/Secrets/Engine_/Shared/Org_/Team_/Name_.elm @@ -371,7 +371,7 @@ view shared route model = [ div [] [ h2 [] [ text <| String.Extra.toTitleCase "edit shared secret" ] , div [ class "secret-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -383,7 +383,7 @@ view shared route model = , msg = \_ -> NoOp , disabled_ = True } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Value" , subtitle = Nothing , id_ = "value" diff --git a/src/elm/Pages/Home_.elm b/src/elm/Pages/Home_.elm index e67b9675b..1dd7646e2 100644 --- a/src/elm/Pages/Home_.elm +++ b/src/elm/Pages/Home_.elm @@ -95,7 +95,7 @@ init : Shared.Model -> () -> ( Model, Effect Msg ) init shared () = ( { favoritesFilter = "" } - , Effect.getCurrentUser {} + , Effect.getCurrentUserShared {} ) @@ -138,7 +138,7 @@ update msg model = -- REFRESH Tick options -> ( model - , Effect.getCurrentUser {} + , Effect.getCurrentUserShared {} ) diff --git a/src/elm/Pages/Org_/Repo_/Deployments.elm b/src/elm/Pages/Org_/Repo_/Deployments.elm index aa1f39ac0..23530f7d8 100644 --- a/src/elm/Pages/Org_/Repo_/Deployments.elm +++ b/src/elm/Pages/Org_/Repo_/Deployments.elm @@ -380,7 +380,7 @@ tableHeaders = -} viewDeployment : Shared.Model -> Vela.Repository -> Vela.Deployment -> Html Msg viewDeployment shared repo deployment = - tr [ Util.testAttribute <| "deployments-row", class "-success" ] + tr [ Util.testAttribute <| "deployments-row", class "status-success" ] [ Components.Table.viewIconCell { dataLabel = "status" , parentClassList = [] diff --git a/src/elm/Pages/Org_/Repo_/Deployments/Add.elm b/src/elm/Pages/Org_/Repo_/Deployments/Add.elm index 170f9916e..7ddb3c05c 100644 --- a/src/elm/Pages/Org_/Repo_/Deployments/Add.elm +++ b/src/elm/Pages/Org_/Repo_/Deployments/Add.elm @@ -346,7 +346,7 @@ view shared route model = _ -> text "" - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Target" , subtitle = Nothing , id_ = "target" @@ -358,7 +358,7 @@ view shared route model = , wrap_ = Just "soft" , msg = TargetOnInput } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Ref" , subtitle = Nothing , id_ = "ref" @@ -373,7 +373,7 @@ view shared route model = , wrap_ = Just "soft" , msg = RefOnInput } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Description" , subtitle = Nothing , id_ = "description" @@ -385,7 +385,7 @@ view shared route model = , wrap_ = Just "soft" , msg = DescriptionOnInput } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Task" , subtitle = Nothing , id_ = "task" @@ -415,7 +415,7 @@ view shared route model = ] ] , div [ class "parameters-inputs" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Nothing , subtitle = Nothing , id_ = "parameter-key" @@ -427,7 +427,7 @@ view shared route model = , wrap_ = Just "soft" , msg = ParameterKeyOnInput } - , Components.Form.viewInput + , Components.Form.viewInputSection { title = Nothing , subtitle = Nothing , id_ = "parameter-value" diff --git a/src/elm/Pages/Org_/Repo_/Hooks.elm b/src/elm/Pages/Org_/Repo_/Hooks.elm index 5a4c75551..2394a042b 100644 --- a/src/elm/Pages/Org_/Repo_/Hooks.elm +++ b/src/elm/Pages/Org_/Repo_/Hooks.elm @@ -470,10 +470,10 @@ hookStatusToRowClass : String -> Html.Attribute msg hookStatusToRowClass status = case status of "success" -> - class "-success" + class "status-success" "skipped" -> - class "-skipped" + class "status-skipped" _ -> - class "-error" + class "status-error" diff --git a/src/elm/Pages/Org_/Repo_/Schedules/Add.elm b/src/elm/Pages/Org_/Repo_/Schedules/Add.elm index ebe2c21ff..e3dfc1e86 100644 --- a/src/elm/Pages/Org_/Repo_/Schedules/Add.elm +++ b/src/elm/Pages/Org_/Repo_/Schedules/Add.elm @@ -239,7 +239,7 @@ view shared route model = else text "" , div [ class "schedule-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -251,7 +251,7 @@ view shared route model = , msg = NameOnInput , disabled_ = formDisabled } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Cron Expression" , subtitle = Just <| Components.ScheduleForm.viewCronHelp shared.time , id_ = "entry" @@ -268,7 +268,7 @@ view shared route model = , value = model.enabled , disabled_ = formDisabled } - , Components.Form.viewInput + , Components.Form.viewInputSection { title = Just "Branch" , subtitle = Just <| diff --git a/src/elm/Pages/Org_/Repo_/Schedules/Name_.elm b/src/elm/Pages/Org_/Repo_/Schedules/Name_.elm index 3b641daad..1055c7609 100644 --- a/src/elm/Pages/Org_/Repo_/Schedules/Name_.elm +++ b/src/elm/Pages/Org_/Repo_/Schedules/Name_.elm @@ -353,7 +353,7 @@ view shared route model = else text "" , div [ class "schedule-form" ] - [ Components.Form.viewInput + [ Components.Form.viewInputSection { title = Just "Name" , subtitle = Nothing , id_ = "name" @@ -370,7 +370,7 @@ view shared route model = , msg = \_ -> NoOp , disabled_ = True } - , Components.Form.viewTextarea + , Components.Form.viewTextareaSection { title = Just "Cron Expression" , subtitle = Just <| Components.ScheduleForm.viewCronHelp shared.time , id_ = "entry" @@ -387,7 +387,7 @@ view shared route model = , value = model.enabled , disabled_ = formDisabled } - , Components.Form.viewInput + , Components.Form.viewInputSection { title = Just "Branch" , subtitle = Just <| diff --git a/src/elm/Route/Path.elm b/src/elm/Route/Path.elm index bb5e5fd81..78b2e7adf 100644 --- a/src/elm/Route/Path.elm +++ b/src/elm/Route/Path.elm @@ -18,6 +18,8 @@ type Path | Account_Logout | Account_Settings | Account_SourceRepos + | Admin_Settings + | Admin_Workers | Dash_Secrets_Engine__Org_Org_ { engine : String, org : String } | Dash_Secrets_Engine__Org_Org__Add { engine : String, org : String } | Dash_Secrets_Engine__Org_Org__Name_ { engine : String, org : String, name : String } @@ -80,6 +82,12 @@ fromString urlPath = "account" :: "source-repos" :: [] -> Just Account_SourceRepos + "admin" :: "settings" :: [] -> + Just Admin_Settings + + "admin" :: "workers" :: [] -> + Just Admin_Workers + "-" :: "secrets" :: engine_ :: "org" :: org_ :: [] -> Dash_Secrets_Engine__Org_Org_ { engine = engine_ @@ -300,6 +308,12 @@ toString path = Account_SourceRepos -> [ "account", "source-repos" ] + Admin_Settings -> + [ "admin", "settings" ] + + Admin_Workers -> + [ "admin", "workers" ] + Dash_Secrets_Engine__Org_Org_ params -> [ "-", "secrets", params.engine, "org", params.org ] diff --git a/src/elm/Shared.elm b/src/elm/Shared.elm index 0684d30d3..ef12ac393 100644 --- a/src/elm/Shared.elm +++ b/src/elm/Shared.elm @@ -448,12 +448,12 @@ update route msg model = Shared.Msg.GetCurrentUser -> ( model , Api.try - Shared.Msg.CurrentUserResponse + Shared.Msg.GetCurrentUserResponse (Api.Operations.getCurrentUser model.velaAPIBaseURL model.session) |> Effect.sendCmd ) - Shared.Msg.CurrentUserResponse response -> + Shared.Msg.GetCurrentUserResponse response -> case response of Ok ( _, user ) -> ( { model | user = RemoteData.succeed user } diff --git a/src/elm/Shared/Msg.elm b/src/elm/Shared/Msg.elm index 782812f97..c8a7c29cb 100644 --- a/src/elm/Shared/Msg.elm +++ b/src/elm/Shared/Msg.elm @@ -45,7 +45,7 @@ type Msg | LogoutResponse { from : Maybe String } (Result (Http.Detailed.Error String) ( Http.Metadata, String )) -- USER | GetCurrentUser - | CurrentUserResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.User )) + | GetCurrentUserResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.User )) -- SOURCE REPOS | UpdateSourceRepos { sourceRepos : WebData Vela.SourceRepositories } -- FAVORITES diff --git a/src/elm/Vela.elm b/src/elm/Vela.elm index e3164d60b..432709ec7 100644 --- a/src/elm/Vela.elm +++ b/src/elm/Vela.elm @@ -26,6 +26,8 @@ module Vela exposing , Name , Org , PipelineConfig + , PlatformSettings + , PlatformSettingsFieldUpdate(..) , Ref , Repo , RepoFieldUpdate(..) @@ -47,6 +49,7 @@ module Vela exposing , Templates , Type , User + , Worker , allowEventsFilterQueryKeys , allowEventsToList , buildRepoPayload @@ -69,15 +72,20 @@ module Vela exposing , decodeSecret , decodeSecrets , decodeServices + , decodeSettings , decodeSourceRepositories , decodeSteps , decodeUser + , decodeWorkers , defaultAllowEvents + , defaultCompilerPayload , defaultDeploymentPayload , defaultEnabledAllowEvents + , defaultQueuePayload , defaultRepoPayload , defaultSchedulePayload , defaultSecretPayload + , defaultSettingsPayload , defaultUpdateUserPayload , enableUpdate , encodeBuildGraphRenderData @@ -86,8 +94,10 @@ module Vela exposing , encodeRepoPayload , encodeSchedulePayload , encodeSecretPayload + , encodeSettingsPayload , encodeUpdateUser , getAllowEventField + , platformSettingsFieldUpdateToResponseConfig , repoFieldUpdateToResponseConfig , secretToKey , secretTypeToString @@ -732,8 +742,8 @@ decodePullActions = |> required "synchronize" bool |> required "edited" bool |> required "reopened" bool - |> required "labeled" bool - |> required "unlabeled" bool + |> optional "labeled" bool False + |> optional "unlabeled" bool False decodeDeployActions : Decoder DeployActions @@ -1855,6 +1865,266 @@ decodeDeploymentParameters = Json.Decode.map decodeKeyValuePairs <| Json.Decode.keyValuePairs Json.Decode.string +type alias Worker = + { id : Int + , host_name : String + , address : String + , routes : List String + , active : Bool + , status : String + , last_status_update : Int + , running_builds : List Build + , last_build_started : Int + , last_build_finished : Int + , last_checked_in : Int + , build_limit : Int + } + + +decodeWorker : Decoder Worker +decodeWorker = + Json.Decode.succeed Worker + |> optional "id" int -1 + |> required "hostname" string + |> required "address" string + |> optional "routes" (Json.Decode.list string) [] + |> optional "active" bool False + |> optional "status" string "" + |> optional "last_status_update_at" int -1 + |> optional "running_builds" decodeBuilds [] + |> optional "last_build_started_at" int -1 + |> optional "last_build_finished_at" int -1 + |> optional "last_checked_in" int -1 + |> optional "build_limit" int -1 + + +decodeWorkers : Decoder (List Worker) +decodeWorkers = + Json.Decode.list decodeWorker + + +type alias PlatformSettings = + { id : Int + , compiler : Compiler + , queue : Queue + , repoAllowlist : List String + , scheduleAllowlist : List String + , createdAt : Int + , updatedAt : Int + , updatedBy : String + } + + +decodeSettings : Decoder PlatformSettings +decodeSettings = + Json.Decode.succeed PlatformSettings + |> optional "id" int -1 + |> required "compiler" decodeCompiler + |> required "queue" decodeQueue + |> required "repo_allowlist" (Json.Decode.list Json.Decode.string) + |> required "schedule_allowlist" (Json.Decode.list Json.Decode.string) + |> required "created_at" int + |> required "updated_at" int + |> required "updated_by" string + + +type alias Compiler = + { cloneImage : String + , templateDepth : Int + , starlarkExecLimit : Int + } + + +decodeCompiler : Decoder Compiler +decodeCompiler = + Json.Decode.succeed Compiler + |> optional "clone_image" string "" + |> optional "template_depth" int -1 + |> optional "starlark_exec_limit" int -1 + + +type alias CompilerPayload = + { cloneImage : Maybe String + , templateDepth : Maybe Int + , starlarkExecLimit : Maybe Int + } + + +defaultCompilerPayload : CompilerPayload +defaultCompilerPayload = + { cloneImage = Nothing + , templateDepth = Nothing + , starlarkExecLimit = Nothing + } + + +encodeCompilerPayload : CompilerPayload -> Json.Encode.Value +encodeCompilerPayload compiler = + Json.Encode.object + [ ( "clone_image", encodeOptional Json.Encode.string compiler.cloneImage ) + , ( "template_depth", encodeOptional Json.Encode.int compiler.templateDepth ) + , ( "starlark_exec_limit", encodeOptional Json.Encode.int compiler.starlarkExecLimit ) + ] + + +type alias Queue = + { routes : List String + } + + +decodeQueue : Decoder Queue +decodeQueue = + Json.Decode.succeed Queue + |> optional "routes" (Json.Decode.list Json.Decode.string) [] + + +type alias QueuePayload = + { routes : Maybe (List String) + } + + +defaultQueuePayload : QueuePayload +defaultQueuePayload = + { routes = Nothing + } + + +encodeQueuePayload : QueuePayload -> Json.Encode.Value +encodeQueuePayload queue = + Json.Encode.object + [ ( "routes", encodeOptional (Json.Encode.list Json.Encode.string) queue.routes ) + ] + + +type alias SettingsPayload = + { compiler : Maybe CompilerPayload + , queue : Maybe QueuePayload + , repoAllowlist : Maybe (List String) + , scheduleAllowlist : Maybe (List String) + } + + +defaultSettingsPayload : SettingsPayload +defaultSettingsPayload = + { compiler = Nothing + , queue = Nothing + , repoAllowlist = Nothing + , scheduleAllowlist = Nothing + } + + +encodeSettingsPayload : SettingsPayload -> Json.Encode.Value +encodeSettingsPayload settings = + Json.Encode.object + [ ( "compiler", encodeOptional encodeCompilerPayload settings.compiler ) + , ( "queue", encodeOptional encodeQueuePayload settings.queue ) + , ( "repo_allowlist", encodeOptional (Json.Encode.list Json.Encode.string) settings.repoAllowlist ) + , ( "schedule_allowlist", encodeOptional (Json.Encode.list Json.Encode.string) settings.scheduleAllowlist ) + ] + + +type PlatformSettingsFieldUpdate + = CompilerCloneImage + | CompilerTemplateDepth + | CompilerStarlarkExecLimit + | QueueRouteAdd String + | QueueRouteUpdate String String + | QueueRouteRemove String + | RepoAllowlistAdd String + | RepoAllowlistUpdate String String + | RepoAllowlistRemove String + | ScheduleAllowlistAdd String + | ScheduleAllowlistUpdate String String + | ScheduleAllowlistRemove String + + +type alias PlatformSettingsUpdateResponseConfig = + { successAlert : PlatformSettings -> String + } + + +platformSettingsFieldUpdateToResponseConfig : PlatformSettingsFieldUpdate -> PlatformSettingsUpdateResponseConfig +platformSettingsFieldUpdateToResponseConfig field = + case field of + CompilerCloneImage -> + { successAlert = + \settings -> + "Compiler clone image set to '" + ++ settings.compiler.cloneImage + ++ "'." + } + + CompilerTemplateDepth -> + { successAlert = + \settings -> + "Compiler template depth set to '" + ++ String.fromInt settings.compiler.templateDepth + ++ "'." + } + + CompilerStarlarkExecLimit -> + { successAlert = + \settings -> + "Compiler Starlark exec limit set to '" + ++ String.fromInt settings.compiler.starlarkExecLimit + ++ "'." + } + + QueueRouteAdd added -> + { successAlert = + \_ -> + "Queue route '" ++ added ++ "' added." + } + + QueueRouteUpdate from to -> + { successAlert = + \_ -> + "Queue route '" ++ from ++ "' updated to'" ++ to ++ "'." + } + + QueueRouteRemove route -> + { successAlert = + \_ -> + "Queue route '" ++ route ++ "' removed." + } + + RepoAllowlistAdd added -> + { successAlert = + \_ -> + "Repo '" ++ added ++ "' added to the overall allowlist." + } + + RepoAllowlistUpdate from to -> + { successAlert = + \_ -> + "Repo '" ++ from ++ "' updated to'" ++ to ++ "' in the overall allowlist." + } + + RepoAllowlistRemove route -> + { successAlert = + \_ -> + "Repo '" ++ route ++ "' removed from the overall allowlist." + } + + ScheduleAllowlistAdd added -> + { successAlert = + \_ -> + "Repo '" ++ added ++ "' added to the schedules allowlist." + } + + ScheduleAllowlistUpdate from to -> + { successAlert = + \_ -> + "Repo '" ++ from ++ "' updated to'" ++ to ++ "' in the schedules allowlist." + } + + ScheduleAllowlistRemove route -> + { successAlert = + \_ -> + "Repo '" ++ route ++ "' removed from the schedules allowlist." + } + + type alias KeyValuePair = { key : String , value : String diff --git a/src/scss/_forms.scss b/src/scss/_forms.scss index 8d1d952c5..af77d96d0 100644 --- a/src/scss/_forms.scss +++ b/src/scss/_forms.scss @@ -111,6 +111,14 @@ flex: 1 0 100%; } } + + &.-wide { + flex: 1; + + input { + flex: 1; + } + } } // radio & checkbox @@ -288,3 +296,24 @@ padding: 0.5rem 0; } } + +.editable-list ul { + padding: 0; + + list-style: none; + background: var(--color-bg-dark); + + .save-button { + margin-left: 0.6rem; + } +} + +.editable-list li { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 3.6rem; + padding: 0.6rem 0.8rem; + + border-bottom: 1px solid var(--color-bg); +} diff --git a/src/scss/_settings.scss b/src/scss/_settings.scss index 385d1e6bc..f79f31b67 100644 --- a/src/scss/_settings.scss +++ b/src/scss/_settings.scss @@ -7,7 +7,8 @@ // styles for the settings pages .repo-settings, -.my-settings { +.my-settings, +.admin-settings { display: flex; flex-wrap: wrap; } @@ -48,3 +49,21 @@ flex-direction: column; margin-right: 0.8em; } + +// site admin platform settings +.settings-info, +.settings-previous-value { + font-size: 1rem; + + span { + padding: 0.2rem 0.4rem; + + font-family: var(--font-code); + + background-color: var(--color-bg-dark); + } +} + +.settings-previous-value { + min-height: 1.6rem; +} diff --git a/src/scss/_table.scss b/src/scss/_table.scss index 1d1fbca09..07aa83995 100644 --- a/src/scss/_table.scss +++ b/src/scss/_table.scss @@ -106,10 +106,6 @@ td .key .list-item { max-width: 24rem; } -.table-base tr.-success { - border-left: 2px solid var(--color-green); -} - .table-base tr.error-data { color: var(--color-red-light); @@ -132,16 +128,31 @@ td .key .list-item { } } -.table-base tr.-error { +.table-base tr.status-success { + border-left: 2px solid var(--color-green); +} + +.table-base tr.status-error { border-bottom: none; border-left: 2px solid var(--color-red); } -.table-base tr.-skipped { +.table-base tr.status-skipped { border-bottom: none; border-left: 2px solid var(--color-lavender); } +.table-base tr.status-busy { + border-bottom: none; + border-left: 2px solid var(--color-yellow); +} + +.table-base tr.status-available, +.table-base tr.status-idle { + border-bottom: none; + border-left: 2px solid var(--color-green); +} + .table-base .error-content { color: var(--color-red-light); font-size: 0.8em; @@ -210,7 +221,7 @@ td .key .list-item { padding-left: 0.6rem; } - .table-base tr.-error { + .table-base tr.status-error { margin-bottom: 0; }