Add 'project' to user table, add 'manager' role, conditional web render

- Addresses issues #40 #45 #82
- Reworked Navigation Header for all roles
- 'Manager' role added, can be assigned a project-id in config by admins
- BREAKING! -> Added 'project' column in SQLite3 table 'user'
- Manager-Assigned project will be added to all graphql filters: Only show Jobs and Users of given project
- 'My Jobs' Tab for all Roles
- Switched from Bool "isAdmin" to integer authLevels
- Removed critical data frontend logging
- Reworked repo.query.SecurityCheck()
This commit is contained in:
Christoph Kluge
2023-01-27 18:36:58 +01:00
parent 834f9d9085
commit b2aed2f16b
33 changed files with 433 additions and 92 deletions

View File

@@ -14,7 +14,7 @@
</script>
{#if user.IsAdmin}
{#if user.AuthLevel == 5}
<Card style="margin-bottom: 1.5em;">
<CardHeader>
<CardTitle class="mb-1">Admin Options</CardTitle>

View File

@@ -4,23 +4,44 @@
Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'sveltestrap'
export let username // empty string if auth. is disabled, otherwise the username as string
export let isAdmin // boolean
export let project // empty string if user has no project in db (= not manager), otherwise the managed projectid as string
export let authlevel // integer
export let clusters // array of names
let isOpen = false
const views = [
isAdmin
? { title: 'Jobs', adminOnly: false, href: '/monitoring/jobs/', icon: 'card-list' }
: { title: 'My Jobs', adminOnly: false, href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Users', adminOnly: true, href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', adminOnly: true, href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', adminOnly: false, href: '/monitoring/tags/', icon: 'tags' }
const userviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const managerviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: `'${project}' Jobs`, href: '/monitoring/jobs/', icon: 'card-list' },
{ title: `'${project}' Users`, href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const supportviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' },
{ title: 'Users', href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const adminviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' },
{ title: 'Users', href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const viewsPerCluster = [
{ title: 'Analysis', adminOnly: true, href: '/monitoring/analysis/', icon: 'graph-up' },
{ title: 'Systems', adminOnly: true, href: '/monitoring/systems/', icon: 'cpu' },
{ title: 'Status', adminOnly: true, href: '/monitoring/status/', icon: 'cpu' },
{ title: 'Analysis', authLevel: 4, href: '/monitoring/analysis/', icon: 'graph-up' },
{ title: 'Systems', authLevel: 5, href: '/monitoring/systems/', icon: 'cpu' },
{ title: 'Status', authLevel: 5, href: '/monitoring/status/', icon: 'cpu' },
]
</script>
@@ -31,10 +52,26 @@
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
<Collapse {isOpen} navbar expand="lg" on:update={({ detail }) => (isOpen = detail.isOpen)}>
<Nav pills>
{#each views.filter(item => isAdmin || !item.adminOnly) as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{#each viewsPerCluster.filter(item => !item.adminOnly || isAdmin) as item}
{#if authlevel == 5} <!-- admin -->
{#each adminviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else if authlevel == 4} <!-- support -->
{#each supportviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else if authlevel == 3} <!-- manager -->
{#each managerviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else if authlevel == 2} <!-- user -->
{#each userviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else}
<p>API User or Unauthorized!</p>
{/if}
{#each viewsPerCluster.filter(item => item.authLevel <= authlevel) as item}
<NavItem>
<Dropdown nav inNavbar>
<DropdownToggle nav caret>
@@ -55,7 +92,7 @@
<div class="d-flex">
<form method="GET" action="/search">
<InputGroup>
<Input type="text" placeholder={isAdmin ? "Search jobId / username" : "Search jobId"} name="searchId"/>
<Input type="text" placeholder={(authlevel >= 4) ? "Search jobId / username" : "Search jobId"} name="searchId"/>
<Button outline type="submit"><Icon name="search"/></Button>
</InputGroup>
</form>

View File

@@ -14,6 +14,8 @@
const ccconfig = getContext('cc-config')
export let filterPresets = {}
export let project = ""
export let isManager = false
let filters, jobList, matchedJobs = null
let sorting = { field: 'startTime', order: 'DESC' }, isSortingOpen = false, isMetricsSelectionOpen = false
@@ -70,6 +72,8 @@
<Row>
<Col>
<JobList
project={project}
isManager={isManager}
bind:metrics={metrics}
bind:sorting={sorting}
bind:matchedJobs={matchedJobs}

View File

@@ -1,4 +1,4 @@
<!--
<!--
@component List of users or projects
-->
<script>
@@ -14,9 +14,19 @@
export let type
export let filterPresets
export let project = false
export let isManager = false
console.assert(type == 'USER' || type == 'PROJECT', 'Invalid list type provided!')
let projectFilter = null
//Setup default filter
if (type == 'USER' && isManager == true && project != '') {
projectFilter = { project: {eq: project} }
} else if (type == 'USER' && isManager == true && project == '') {
projectFilter = { project: {eq: "noProjectForManager"} }
}
const stats = operationStore(`query($filter: [JobFilter!]!) {
rows: jobsStatistics(filter: $filter, groupBy: ${type}) {
id
@@ -54,7 +64,7 @@
: (sorting.direction == 'up'
? (a, b) => a[sorting.field] - b[sorting.field]
: (a, b) => b[sorting.field] - a[sorting.field])
return stats.filter(u => u.id.includes(nameFilter)).sort(cmp)
}
@@ -78,6 +88,9 @@
menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up"
on:update={({ detail }) => {
$stats.variables = { filter: detail.filters }
if (projectFilter != null) {
$stats.variables.filter.push(projectFilter)
}
$stats.context.pause = false
$stats.reexecute()
}} />
@@ -148,4 +161,4 @@
{/each}
{/if}
</tbody>
</Table>
</Table>

View File

@@ -48,7 +48,7 @@
query(nodesQuery)
$: console.log($nodesQuery?.data?.nodeMetrics[0].metrics)
// $: console.log($nodesQuery?.data?.nodeMetrics[0].metrics)
</script>
<Row>

View File

@@ -48,7 +48,7 @@
if (s1 == null || s2 == null)
return -1
return s.dir != 'up' ? s1[stat] - s2[stat] : s2[stat] - s1[stat]
return s.dir != 'up' ? s1[stat] - s2[stat] : s2[stat] - s1[stat]
})
}
@@ -61,7 +61,7 @@
<thead>
<tr>
<th>
<Button outline on:click={() => (isMetricSelectionOpen = true, console.log(isMetricSelectionOpen))}>
<Button outline on:click={() => (isMetricSelectionOpen = true)}> <!-- log to click ', console.log(isMetricSelectionOpen)' -->
Metrics
</Button>
</th>

View File

@@ -2,6 +2,7 @@
import { Row, Col } from 'sveltestrap'
import { onMount } from 'svelte'
import EditRole from './admin/EditRole.svelte'
import EditProject from './admin/EditProject.svelte'
import AddUser from './admin/AddUser.svelte'
import ShowUsers from './admin/ShowUsers.svelte'
import Options from './admin/Options.svelte'
@@ -30,6 +31,9 @@
<Col>
<EditRole on:reload={getUserList}/>
</Col>
<Col>
<EditProject on:reload={getUserList}/>
</Col>
<Col>
<Options/>
</Col>

View File

@@ -45,17 +45,7 @@
<form id="create-user-form" method="post" action="/api/users/" class="card-body" on:submit|preventDefault={handleUserSubmit}>
<CardTitle class="mb-3">Create User</CardTitle>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" aria-describedby="nameHelp"/>
<div id="nameHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" aria-describedby="emailHelp"/>
<div id="emailHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<label for="username" class="form-label">Username (ID)</label>
<input type="text" class="form-control" id="username" name="username" aria-describedby="usernameHelp"/>
<div id="usernameHelp" class="form-text">Must be unique.</div>
</div>
@@ -64,6 +54,23 @@
<input type="password" class="form-control" id="password" name="password" aria-describedby="passwordHelp"/>
<div id="passwordHelp" class="form-text">Only API users are allowed to have a blank password. Users with a blank password can only authenticate via Tokens.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Project</label>
<input type="text" class="form-control" id="project" name="project" aria-describedby="projectHelp"/>
<div id="projectHelp" class="form-text">Only Manager users can have a project. Allows to inspect jobs and users of given project.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" aria-describedby="nameHelp"/>
<div id="nameHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" aria-describedby="emailHelp"/>
<div id="emailHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<p>Role:</p>
<div>
@@ -74,6 +81,10 @@
<input type="radio" id="api" name="role" value="api"/>
<label for="api">API</label>
</div>
<div>
<input type="radio" id="manager" name="role" value="manager"/>
<label for="manager">Manager</label>
</div>
<div>
<input type="radio" id="support" name="role" value="support"/>
<label for="support">Support</label>

View File

@@ -0,0 +1,97 @@
<script>
import { Card, CardTitle, CardBody } from 'sveltestrap'
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
const dispatch = createEventDispatcher()
let message = {msg: '', color: '#d63384'}
let displayMessage = false
async function handleAddProject() {
const username = document.querySelector('#project-username').value
const project = document.querySelector('#project-id').value
if (username == "" || project == "") {
alert('Please fill in a username and select a project.')
return
}
let formData = new FormData()
formData.append('username', username)
formData.append('add-project', project)
try {
const res = await fetch(`/api/user/${username}`, { method: 'POST', body: formData })
if (res.ok) {
let text = await res.text()
popMessage(text, '#048109')
reloadUserList()
} else {
let text = await res.text()
// console.log(res.statusText)
throw new Error('Response Code ' + res.status + '-> ' + text)
}
} catch (err) {
popMessage(err, '#d63384')
}
}
async function handleRemoveProject() {
const username = document.querySelector('#project-username').value
const project = document.querySelector('#project-id').value
if (username == "" || project == "") {
alert('Please fill in a username and select a project.')
return
}
let formData = new FormData()
formData.append('username', username)
formData.append('remove-project', project)
try {
const res = await fetch(`/api/user/${username}`, { method: 'POST', body: formData })
if (res.ok) {
let text = await res.text()
popMessage(text, '#048109')
reloadUserList()
} else {
let text = await res.text()
// console.log(res.statusText)
throw new Error('Response Code ' + res.status + '-> ' + text)
}
} catch (err) {
popMessage(err, '#d63384')
}
}
function popMessage(response, rescolor) {
message = {msg: response, color: rescolor}
displayMessage = true
setTimeout(function() {
displayMessage = false
}, 3500)
}
function reloadUserList() {
dispatch('reload')
}
</script>
<Card>
<CardBody>
<CardTitle class="mb-3">Edit Project Managed By User (Manager Only)</CardTitle>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="username" id="project-username"/>
<input type="text" class="form-control" placeholder="project-id" id="project-id"/>
<!-- PreventDefault on Sveltestrap-Button more complex to achieve than just use good ol' html button -->
<!-- see: https://stackoverflow.com/questions/69630422/svelte-how-to-use-event-modifiers-in-my-own-components -->
<button class="btn btn-primary" type="button" id="add-project-button" on:click|preventDefault={handleAddProject}>Reset</button>
<button class="btn btn-danger" type="button" id="remove-project-button" on:click|preventDefault={handleRemoveProject}>Remove</button>
</div>
<p>
{#if displayMessage}<b><code style="color: {message.color};" out:fade>Update: {message.msg}</code></b>{/if}
</p>
</CardBody>
</Card>

View File

@@ -87,6 +87,7 @@
<select class="form-select" id="role-select">
<option selected value="">Role...</option>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="support">Support</option>
<option value="admin">Admin</option>
<option value="api">API</option>

View File

@@ -41,6 +41,7 @@
<tr>
<th>Username</th>
<th>Name</th>
<th>Project</th>
<th>Email</th>
<th>Roles</th>
<th>JWT</th>

View File

@@ -16,6 +16,7 @@
<td>{user.username}</td>
<td>{user.name}</td>
<td>{user.project}</td>
<td>{user.email}</td>
<td><code>{user.roles.join(', ')}</code></td>
<td>

View File

@@ -115,7 +115,7 @@
opts.push(`state=${state}`)
if (filters.startTime.from && filters.startTime.to)
opts.push(`startTime=${dateToUnixEpoch(filters.startTime.from)}-${dateToUnixEpoch(filters.startTime.to)}`)
for (let tag of filters.tags)
for (let tag of filters.tags)
opts.push(`tag=${tag}`)
if (filters.duration.from && filters.duration.to)
opts.push(`duration=${filters.duration.from}-${filters.duration.to}`)
@@ -214,7 +214,7 @@
on:change={({ detail: { from, to } }) => {
filters.startTime.from = from?.toISOString()
filters.startTime.to = to?.toISOString()
console.log(filters.startTime)
// console.log(filters.startTime)
update()
}}
/>

View File

@@ -1,7 +1,7 @@
<script>
import { createEventDispatcher, getContext } from 'svelte'
import { Button, Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
import Header from '../Header.svelte';
import Header from '../Header.svelte';
import DoubleRangeSlider from './DoubleRangeSlider.svelte'
const clusters = getContext('clusters'),
@@ -23,7 +23,7 @@ import Header from '../Header.svelte';
const findMaxNumAccels = clusters => clusters.reduce((max, cluster) => Math.max(max,
cluster.subClusters.reduce((max, sc) => Math.max(max, sc.topology.accelerators?.length || 0), 0)), 0)
console.log(header)
// console.log(header)
let minNumNodes = 1, maxNumNodes = 0, minNumHWThreads = 1, maxNumHWThreads = 0, minNumAccelerators = 0, maxNumAccelerators = 0
$: {
if ($initialized) {

View File

@@ -23,10 +23,20 @@
export let sorting = { field: "startTime", order: "DESC" }
export let matchedJobs = 0
export let metrics = ccconfig.plot_list_selectedMetrics
export let project
export let isManager
let itemsPerPage = ccconfig.plot_list_jobsPerPage
let page = 1
let paging = { itemsPerPage, page }
let filter = []
//Setup default filter
if (isManager == true && project != '') {
filter.push({project: {eq: project}})
} else if (isManager == true && project == '') {
filter.push({project: {eq: "noProjectForManager"}})
}
const jobs = operationStore(`
query($filter: [JobFilter!]!, $sorting: OrderByInput!, $paging: PageRequest! ){
@@ -45,7 +55,7 @@
}`, {
paging,
sorting,
filter: []
filter,
}, {
pause: true
})
@@ -67,8 +77,15 @@
filters.push({ minRunningFor })
}
// (Re-)Add Manager-Filter
if (isManager == true && project != '') {
filters.push({project: {eq: project}})
} else if (isManager == true && project == '') {
filters.push({project: {eq: "noProjectForManager"}})
}
$jobs.variables.filter = filters
console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
// console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
}
page = 1

View File

@@ -4,7 +4,9 @@ import Jobs from './Jobs.root.svelte'
new Jobs({
target: document.getElementById('svelte-app'),
props: {
filterPresets: filterPresets
filterPresets: filterPresets,
project: project,
isManager: isManager
},
context: new Map([
['cc-config', clusterCockpitConfig]

View File

@@ -6,6 +6,8 @@ new List({
props: {
filterPresets: filterPresets,
type: listType,
project: project,
isManager: isManager
},
context: new Map([
['cc-config', clusterCockpitConfig]

View File

@@ -1,4 +1,4 @@
<!--
<!--
@component
Only width/height should change reactively.
@@ -285,7 +285,7 @@
else if (scope == 'hwthread')
divisor = subCluster.topology.node.length
else {
console.log('TODO: how to calc thresholds for ', scope)
// console.log('TODO: how to calc thresholds for ', scope)
return null
}

View File

@@ -16,7 +16,8 @@
<script>
const header = {
"username": "{{ .User.Username }}",
"isAdmin": {{ .User.IsAdmin }},
"project": "{{ .User.Project }}",
"authlevel": {{ .User.AuthLevel }},
"clusters": {{ .Clusters }},
};
</script>

View File

@@ -9,14 +9,14 @@
<th>Running Jobs (short ones not listed)</th>
<th>Total Jobs</th>
<th>Short Jobs in past 24h</th>
{{if .User.IsAdmin}}
{{if ge .User.AuthLevel 4}}
<th>System View</th>
<th>Analysis View</th>
{{end}}
</tr>
</thead>
<tbody>
{{if .User.IsAdmin}}
{{if ge .User.AuthLevel 4}}
{{range .Infos.clusters}}
<tr>
<td>{{.Name}}</td>

View File

@@ -10,6 +10,8 @@
<script>
const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }};
const project = {{ .User.Project }};
const isManager = {{ eq .User.AuthLevel 3 }};
</script>
<script src='/build/jobs.js'></script>
{{end}}

View File

@@ -10,6 +10,8 @@
const listType = {{ .Infos.listType }};
const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }};
const project = {{ .User.Project }};
const isManager = {{ eq .User.AuthLevel 3 }};
</script>
<script src='/build/list.js'></script>
{{end}}

View File

@@ -55,8 +55,8 @@ func init() {
type User struct {
Username string // Username of the currently logged in user
IsAdmin bool
IsSupporter bool
Project string // Project of the user (relevant for managers only)
AuthLevel int // Level of authorization
}
type Build struct {