Skip to content

Commit

Permalink
Add cluster member controls and UI #147
Browse files Browse the repository at this point in the history
  • Loading branch information
NHAS committed Nov 24, 2024
1 parent 49a0f90 commit d6866cb
Show file tree
Hide file tree
Showing 15 changed files with 241 additions and 32 deletions.
14 changes: 9 additions & 5 deletions adminui/clustering.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func (au *AdminUI) members(w http.ResponseWriter, r *http.Request) {
}

members = append(members, MembershipDTO{
ID: member.ID,
ID: member.ID.String(),
PeerUrls: member.PeerURLs,
Name: member.Name,
IsLearner: member.IsLearner,
Expand All @@ -86,21 +86,25 @@ func (au *AdminUI) newNode(w http.ResponseWriter, r *http.Request) {
var (
newNodeReq NewNodeRequestDTO
newNodeResp NewNodeResponseDTO
err error
)

defer func() {
w.Header().Set("Content-Type", "application/json")
if err != nil {
newNodeResp.ErrorMessage = err.Error()
}
json.NewEncoder(w).Encode(newNodeResp)
}()

newNodeResp.ErrorMessage = json.NewDecoder(r.Body).Decode(&newNodeReq)
if newNodeResp.ErrorMessage != nil {
err = json.NewDecoder(r.Body).Decode(&newNodeReq)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}

newNodeResp.JoinToken, newNodeResp.ErrorMessage = data.AddMember(newNodeReq.NodeName, newNodeReq.ConnectionURL, newNodeReq.ManagerURL)
if newNodeResp.ErrorMessage != nil {
newNodeResp.JoinToken, err = data.AddMember(newNodeReq.NodeName, newNodeReq.ConnectionURL, newNodeReq.ManagerURL)
if err != nil {
log.Println("failed to add member: ", newNodeResp.ErrorMessage)
w.WriteHeader(http.StatusInternalServerError)
return
Expand Down
2 changes: 0 additions & 2 deletions adminui/frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export function apiRefreshAuth(): Promise<AuthLoginResponseDTO> {
return client.post('/api/refresh').then(res => res.data)
}



export function logout() {
return client.get('/api/logout')
}
18 changes: 17 additions & 1 deletion adminui/frontend/src/api/cluster.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { ClusterEvents, ClusterMember, AcknowledgeErrorResponseDTO, GenericResponseDTO } from './types'
import type {
ClusterEvents,
ClusterMember,
AcknowledgeErrorResponseDTO,
GenericResponseDTO,
NewNodeRequestDTO,
NewNodeResponseDTO,
NodeControlRequestDTO
} from './types'

import { client } from '.'

Expand All @@ -13,3 +21,11 @@ export function acknowledgeClusterError(error: AcknowledgeErrorResponseDTO): Pro
export function getClusterMembers(): Promise<ClusterMember[]> {
return client.get('/api/cluster/members').then(res => res.data)
}

export function addClusterMember(newNode: NewNodeRequestDTO): Promise<NewNodeResponseDTO> {
return client.post('/api/cluster/members', newNode).then(res => res.data)
}

export function editClusterMember(action: NodeControlRequestDTO): Promise<GenericResponseDTO> {
return client.put('/api/cluster/members', action).then(res => res.data)
}
2 changes: 1 addition & 1 deletion adminui/frontend/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import { client } from '.'

export function getConfig(): Promise<ConfigResponseDTO> {
return client.get('/api/config').then(res => res.data)
}
}
2 changes: 1 addition & 1 deletion adminui/frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export * from './account'
export * from './server_info'
export * from './settings'
export * from './diagnostics'
export * from './config'
export * from './config'
26 changes: 25 additions & 1 deletion adminui/frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,28 @@ export interface AcknowledgeErrorResponseDTO {
export interface ConfigResponseDTO {
sso: boolean
password: boolean
}
}

export interface NewNodeRequestDTO {
node_name: string
connection_url: string
manager_url: string
}

export interface NewNodeResponseDTO {
join_token: string
error_message: string
}

export interface NodeControlRequestDTO {
node: string
action: NodeControlActions
}

export enum NodeControlActions {
Promote = 'promote',
Drain = 'drain',
Restore = 'restore',
Stepdown = 'stepdown',
Remove = 'remove'
}
6 changes: 3 additions & 3 deletions adminui/frontend/src/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import logo from '../../public/WagLogo.png'
import { useAuthStore } from '@/stores/auth'
import { useInstanceDetailsStore } from '@/stores/serverInfo'
import { Icons } from '@/util/icons'
import logo from "../../public/WagLogo.png"
const authStore = useAuthStore()
const { loggedInUser } = storeToRefs(authStore)
Expand Down Expand Up @@ -70,7 +70,7 @@ async function logout() {
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<aside class="flex min-h-full w-72 flex-col p-4 bg-neutral text-neutral-content">
<RouterLink to="/dashboard">
<h2 class="btn btn-ghost w-full text-center text-3xl">Wag<img class="h-14" :src="logo"/></h2>
<h2 class="btn btn-ghost w-full text-center text-3xl">Wag<img class="h-14" :src="logo" /></h2>
<div class="w-full text-center" v-if="info.serverInfo.version != ''">
<small class="text-center font-mono text-xs">{{ info.serverInfo.version }}</small>
</div>
Expand Down
2 changes: 1 addition & 1 deletion adminui/frontend/src/pages/ClusterEvents.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function openErrorInspectionModal(error: EventErrorDTO) {
}
const isInspectionModalOpen = ref(false)
const inspectedEvent = ref<GeneralEvent>({state: {previous: "", current: ""}} as GeneralEvent)
const inspectedEvent = ref<GeneralEvent>({ state: { previous: '', current: '' } } as GeneralEvent)
function openEventInspectionModal(error: GeneralEvent) {
inspectedEvent.value = error
isInspectionModalOpen.value = true
Expand Down
181 changes: 177 additions & 4 deletions adminui/frontend/src/pages/ClusterMembers.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<script setup lang="ts">
import { getClusterMembers } from '@/api/cluster'
import { ref } from 'vue'
import { useToast } from 'vue-toastification'
import Modal from '@/components/Modal.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import { addClusterMember, editClusterMember, getClusterMembers } from '@/api/cluster'
import { useApi } from '@/composables/useApi'
import { useToastError } from '@/composables/useToastError'
import type { ClusterMember } from '@/api'
import { Icons } from '@/util/icons'
const { data: members } = useApi(() => getClusterMembers())
import { type NewNodeRequestDTO, type ClusterMember, NodeControlActions, type NodeControlRequestDTO } from '@/api'
const { data: members, silentlyRefresh: refresh } = useApi(() => getClusterMembers())
function nodeName(member: ClusterMember): string {
let result = member.name
Expand All @@ -19,16 +28,149 @@ function nodeName(member: ClusterMember): string {
return result
}
const toast = useToast()
const { catcher } = useToastError()
const isMemberAddModalOpen = ref(false)
const newMemberDetails = ref<NewNodeRequestDTO>({
connection_url: '',
manager_url: '',
node_name: ''
} as NewNodeRequestDTO)
async function addMember() {
if (newMemberDetails.value?.connection_url.length == 0) {
toast.error('Peer URL must be defined')
return
}
try {
const resp = await addClusterMember(newMemberDetails.value)
refresh()
if (resp.error_message) {
toast.error(resp.error_message)
return
} else {
toast.info(
`New join token: ${resp.join_token}\nThis will not be displayed again, valid 30 seconds\nUse 'wag start -token ${resp.join_token}'`,
{
timeout: false,
closeOnClick: false,
draggable: false
}
)
}
} catch (e) {
catcher(e, 'failed to add new cluster member: ')
}
}
function openAddMemberModal() {
isMemberAddModalOpen.value = true
newMemberDetails.value = {
connection_url: '',
manager_url: '',
node_name: ''
} as NewNodeRequestDTO
}
async function controlNode(member: ClusterMember, action: NodeControlActions) {
try {
const req: NodeControlRequestDTO = {
action: action,
node: member.id
}
const resp = await editClusterMember(req)
if (!resp.success) {
toast.error(resp.message ?? 'Failed')
return
} else {
toast.success(`Node ${member.id} was ${action}, successfully!`)
refresh()
}
} catch (e) {
catcher(e, 'failed to add new cluster member: ')
}
}
</script>

<template>
<main class="w-full p-4">
<Modal v-model:isOpen="isMemberAddModalOpen">
<div class="w-screen max-w-[600px]">
<h3 class="text-lg font-bold">Add Node</h3>
<div class="mt-2">
<p>Add member to wag cluster</p>

<div class="form-group">
<label for="group" class="block font-medium text-gray-900 pt-6"
>Peer URL:
<input
type="url"
class="input input-bordered input-sm w-full"
id="nodeURL"
name="nodeURL"
v-model="newMemberDetails.connection_url"
/>
</label>
</div>

<div class="form-group">
<label for="group" class="block font-medium text-gray-900 pt-6"
>New Node Label:
<input
type="text"
class="input input-bordered input-sm w-full"
id="newNodeName"
placeholder="(Optional)"
v-model="newMemberDetails.node_name"
/>
</label>
</div>

<div class="form-group">
<label for="group" class="block font-medium text-gray-900 pt-6"
>Manager URL:
<input
type="text"
class="input input-bordered input-sm w-full"
id="managerURL"
placeholder="(Optional)"
v-model="newMemberDetails.manager_url"
/>
</label>
</div>

<span class="mt-8 flex">
<button class="btn btn-primary" @click="() => addMember()">Add</button>

<div class="flex flex-grow"></div>

<button class="btn btn-secondary" @click="() => (isMemberAddModalOpen = false)">Cancel</button>
</span>
</div>
</div>
</Modal>
<h1 class="text-4xl font-bold">Cluster Members</h1>
<button class="btn btn-ghost btn-primary" @click="openAddMemberModal">
Add Cluster Member <font-awesome-icon :icon="Icons.Add" />
</button>

<div class="mt-6 flex flex-wrap gap-6">
<div class="grid w-full grid-cols-4 gap-4">
<div v-for="member in members" class="card-compact bg-base-100 shadow-xl min-w-96 max-w-96" :key="member.id">
<div class="card-body">
<h5 class="card-title overflow-hidden text-ellipsis whitespace-nowrap">{{ nodeName(member) }}</h5>
<h5 class="card-title overflow-hidden text-ellipsis whitespace-nowrap justify-between">
<span>{{ nodeName(member) }}</span>
<ConfirmModal v-if="!member.current_node" @on-confirm="() => controlNode(member, NodeControlActions.Remove)">
<button><font-awesome-icon class="text-error hover:text-error-focus" :icon="Icons.Delete" /></button>
</ConfirmModal>
</h5>

<div class="grid grid-cols-2 gap-2">
<div>ID:</div>
Expand All @@ -55,6 +197,37 @@ function nodeName(member: ClusterMember): string {
</div>
</div>
</div>
<div class="mt-4 flex flex-row justify-between">
<button
v-if="member.learner"
class="btn btn-sm btn-info"
@click="() => controlNode(member, NodeControlActions.Promote)"
:disabled="member.name.length == 0"
>
Promote
</button>
<button v-if="member.leader" class="btn btn-sm btn-info" @click="() => controlNode(member, NodeControlActions.Stepdown)">
Step Down
</button>
<span v-if="!member.witness">
<button
v-if="member.drained"
class="btn btn-sm btn-warning"
@click="() => controlNode(member, NodeControlActions.Restore)"
:disabled="member.name.length == 0"
>
Restore
</button>
<button
v-else
class="btn btn-sm btn-info"
@click="() => controlNode(member, NodeControlActions.Drain)"
:disabled="member.name.length == 0"
>
Drain
</button>
</span>
</div>
</div>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions adminui/frontend/src/pages/Groups.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Modal from '@/components/Modal.vue'
import PaginationControls from '@/components/PaginationControls.vue'
import PageLoading from '@/components/PageLoading.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import EmptyTable from '@/components/EmptyTable.vue'
import { useApi } from '@/composables/useApi'
import { usePagination } from '@/composables/usePagination'
Expand All @@ -15,7 +16,6 @@ import { useTextareaInput } from '@/composables/useTextareaInput'
import { Icons } from '@/util/icons'
import { getAllGroups, type GroupDTO, editGroup, createGroup, deleteGroups } from '@/api'
import EmptyTable from '@/components/EmptyTable.vue'
const { data: groupsData, isLoading: isLoadingRules, silentlyRefresh: refreshGroups } = useApi(() => getAllGroups())
Expand Down Expand Up @@ -218,7 +218,6 @@ async function tryDeleteGroups(groups: string[]) {
<EmptyTable v-if="allGroups.length == 0" text="No groups" />
<EmptyTable v-if="allGroups.length != 0 && allGroups.length == 0" text="No matching groups" />


<div class="mt-2 w-full text-center">
<PaginationControls @next="() => nextPage()" @prev="() => prevPage()" :current-page="activePage" :total-pages="totalPages" />
</div>
Expand Down
Loading

0 comments on commit d6866cb

Please sign in to comment.