Added onboarding session progress view logic
This commit is contained in:
parent
a4f0fb3ea6
commit
83d6f38a24
6 changed files with 241 additions and 160 deletions
|
|
@ -54,13 +54,22 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
await self.run_full_onboarding_generation(role_uuid)
|
await self.run_full_onboarding_generation(role_uuid)
|
||||||
elif action == "progress_monitor":
|
elif action == "progress_monitor":
|
||||||
role_uuid = data.get("role_uuid") or self.context_uuid
|
role_uuid = data.get("role_uuid") or self.context_uuid
|
||||||
|
target_user_uuid = data.get("user_uuid")
|
||||||
|
flow_uuid = data.get("flow_uuid")
|
||||||
if not role_uuid:
|
if not role_uuid:
|
||||||
await self.send_log("error", "Missing role_uuid for progress monitoring")
|
await self.send_log("error", "Missing role_uuid for progress monitoring")
|
||||||
return
|
return
|
||||||
if not await self.can_access_role(role_uuid, self.user.id):
|
if not await self.can_access_role(role_uuid, self.user.id):
|
||||||
await self.send_log("error", "Forbidden")
|
await self.send_log("error", "Forbidden")
|
||||||
return
|
return
|
||||||
await self.run_progress_monitor(role_uuid)
|
target_user_id = self.user.id
|
||||||
|
if target_user_uuid and str(target_user_uuid) != str(self.user.uuid):
|
||||||
|
target_user_id = await self.resolve_target_user_id(role_uuid, self.user.id, target_user_uuid)
|
||||||
|
if not target_user_id:
|
||||||
|
await self.send_log("error", "Forbidden")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.run_progress_monitor(role_uuid, target_user_id=target_user_id, flow_uuid=flow_uuid)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
user_message = data.get("query") or data.get("message")
|
user_message = data.get("query") or data.get("message")
|
||||||
|
|
@ -232,7 +241,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
"message": "Onboarding pipeline complete and structure saved."
|
"message": "Onboarding pipeline complete and structure saved."
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async def run_progress_monitor(self, role_uuid):
|
async def run_progress_monitor(self, role_uuid, target_user_id=None, flow_uuid=None):
|
||||||
await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor")
|
await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor")
|
||||||
|
|
||||||
monitor_config = await self.get_config_by_type(role_uuid, 'monitor')
|
monitor_config = await self.get_config_by_type(role_uuid, 'monitor')
|
||||||
|
|
@ -240,7 +249,11 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
await self.send_log("error", "Missing Progress Monitor AgentConfig for this role")
|
await self.send_log("error", "Missing Progress Monitor AgentConfig for this role")
|
||||||
return
|
return
|
||||||
|
|
||||||
progress_context = await self.get_role_progress_context(role_uuid, self.user.id)
|
progress_context = await self.get_role_progress_context(
|
||||||
|
role_uuid,
|
||||||
|
target_user_id or self.user.id,
|
||||||
|
flow_uuid=flow_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
monitor_prompt = (
|
monitor_prompt = (
|
||||||
"You are a progress monitoring agent for onboarding. "
|
"You are a progress monitoring agent for onboarding. "
|
||||||
|
|
@ -250,12 +263,21 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
f"Progress context JSON:\n{json.dumps(progress_context)}"
|
f"Progress context JSON:\n{json.dumps(progress_context)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
feedback = await self.orchestrate_ai(
|
try:
|
||||||
monitor_prompt,
|
feedback = await self.orchestrate_ai(
|
||||||
monitor_config,
|
monitor_prompt,
|
||||||
min_internal_turns=1,
|
monitor_config,
|
||||||
max_tokens=640,
|
min_internal_turns=1,
|
||||||
)
|
max_tokens=640,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await self.send_log("error", f"Inference failed: {str(exc)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if str(feedback).startswith("Error:"):
|
||||||
|
await self.send_log("error", str(feedback))
|
||||||
|
return
|
||||||
|
|
||||||
await self.send(json.dumps({
|
await self.send(json.dumps({
|
||||||
"type": "completed",
|
"type": "completed",
|
||||||
|
|
@ -265,6 +287,9 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
"role_uuid": role_uuid,
|
"role_uuid": role_uuid,
|
||||||
"feedback": feedback,
|
"feedback": feedback,
|
||||||
"status": progress_context.get("latest_status", "unknown"),
|
"status": progress_context.get("latest_status", "unknown"),
|
||||||
|
"user_id": target_user_id or self.user.id,
|
||||||
|
"flow_uuid": flow_uuid,
|
||||||
|
"is_completed": progress_context.get("is_completed", False),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -275,6 +300,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
min_internal_turns=2,
|
min_internal_turns=2,
|
||||||
max_turns=6,
|
max_turns=6,
|
||||||
max_tokens=None,
|
max_tokens=None,
|
||||||
|
raise_on_error=False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Handles the multi-turn ReAct loop (Reasoning + Tool Use).
|
Handles the multi-turn ReAct loop (Reasoning + Tool Use).
|
||||||
|
|
@ -360,6 +386,8 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.send_log("error", f"Inference failed: {str(e)}")
|
await self.send_log("error", f"Inference failed: {str(e)}")
|
||||||
|
if raise_on_error:
|
||||||
|
raise
|
||||||
return f"Error: {str(e)}"
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
return last_content
|
return last_content
|
||||||
|
|
@ -663,13 +691,20 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
).order_by('-updated_at').first()
|
).order_by('-updated_at').first()
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def get_role_progress_context(self, role_uuid, user_id):
|
def get_role_progress_context(self, role_uuid, user_id, flow_uuid=None):
|
||||||
from apps.accounts.models import Role
|
from apps.accounts.models import Role
|
||||||
|
|
||||||
role = Role.objects.get(uuid=role_uuid)
|
role = Role.objects.get(uuid=role_uuid)
|
||||||
sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at')
|
|
||||||
latest_session = sessions.first()
|
|
||||||
active_flow = OnboardingFlow.objects.filter(role=role, is_active=True).order_by('-updated_at').first()
|
active_flow = OnboardingFlow.objects.filter(role=role, is_active=True).order_by('-updated_at').first()
|
||||||
|
scoped_flow = None
|
||||||
|
if flow_uuid:
|
||||||
|
scoped_flow = OnboardingFlow.objects.filter(role=role, uuid=flow_uuid).first()
|
||||||
|
|
||||||
|
sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at')
|
||||||
|
if flow_uuid:
|
||||||
|
sessions = sessions.filter(state__flow_uuid=str(flow_uuid))
|
||||||
|
|
||||||
|
latest_session = sessions.first()
|
||||||
|
|
||||||
if not latest_session:
|
if not latest_session:
|
||||||
return {
|
return {
|
||||||
|
|
@ -677,10 +712,12 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
"role_name": role.name,
|
"role_name": role.name,
|
||||||
"latest_status": "not_started",
|
"latest_status": "not_started",
|
||||||
"session_count": 0,
|
"session_count": 0,
|
||||||
"flow_exists": bool(active_flow),
|
"flow_exists": bool(scoped_flow or active_flow),
|
||||||
|
"flow_uuid": str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"responses_count": 0,
|
"responses_count": 0,
|
||||||
"completed_modules": [],
|
"completed_modules": [],
|
||||||
|
"is_completed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
state = latest_session.state or {}
|
state = latest_session.state or {}
|
||||||
|
|
@ -693,9 +730,31 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
|
||||||
"role_name": role.name,
|
"role_name": role.name,
|
||||||
"latest_status": latest_session.status,
|
"latest_status": latest_session.status,
|
||||||
"session_count": sessions.count(),
|
"session_count": sessions.count(),
|
||||||
"flow_exists": bool(active_flow),
|
"flow_exists": bool(scoped_flow or active_flow),
|
||||||
|
"flow_uuid": str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None,
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
"responses_count": len(responses) if isinstance(responses, dict) else 0,
|
"responses_count": len(responses) if isinstance(responses, dict) else 0,
|
||||||
"completed_modules": completed_modules if isinstance(completed_modules, list) else [],
|
"completed_modules": completed_modules if isinstance(completed_modules, list) else [],
|
||||||
"updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None,
|
"updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None,
|
||||||
|
"is_completed": latest_session.status == 'completed',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def resolve_target_user_id(self, role_uuid, requester_id, target_user_uuid):
|
||||||
|
from apps.accounts.models import Role, User
|
||||||
|
|
||||||
|
role = Role.objects.filter(uuid=role_uuid).first()
|
||||||
|
requester = User.objects.filter(id=requester_id).first()
|
||||||
|
target = User.objects.filter(uuid=target_user_uuid).first()
|
||||||
|
if role is None or requester is None or target is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_owner = role.organization.owner.id == requester_id
|
||||||
|
is_manager_member = bool(requester.is_manager) and role.organization.members.filter(id=requester_id).exists()
|
||||||
|
if not (is_owner or is_manager_member):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not role.members.filter(id=target.id).exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return target.id
|
||||||
|
|
@ -144,6 +144,7 @@ export const API = {
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
list: () => 'onboarding-session/',
|
list: () => 'onboarding-session/',
|
||||||
|
progressOverview: () => 'onboarding-session/progress-overview/',
|
||||||
byId: (uuid: string) => `onboarding-session/${uuid}/`,
|
byId: (uuid: string) => `onboarding-session/${uuid}/`,
|
||||||
interact: (uuid: string) => `onboarding-session/${uuid}/interact/`,
|
interact: (uuid: string) => `onboarding-session/${uuid}/interact/`,
|
||||||
askKa: (uuid: string) => `onboarding-session/${uuid}/ask-ka/`,
|
askKa: (uuid: string) => `onboarding-session/${uuid}/ask-ka/`,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ export type OnboardingFlow = {
|
||||||
agent?: string | null
|
agent?: string | null
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
status: 'draft' | 'published' | 'archived'
|
|
||||||
pages?: OnboardingPage[]
|
pages?: OnboardingPage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +62,12 @@ export type ProgressSessionApi = {
|
||||||
uuid: string
|
uuid: string
|
||||||
status: OnboardingSessionStatus
|
status: OnboardingSessionStatus
|
||||||
role: UuidNameRef
|
role: UuidNameRef
|
||||||
|
user?: {
|
||||||
|
uuid: string
|
||||||
|
email_address?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
}
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
state?: Record<string, unknown>
|
state?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
@ -81,3 +86,26 @@ export type RoleProgressItem = {
|
||||||
feedback?: string
|
feedback?: string
|
||||||
loadingFeedback: boolean
|
loadingFeedback: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProgressOverviewItem = {
|
||||||
|
role: UuidNameRef
|
||||||
|
user: {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
flow: {
|
||||||
|
uuid: string
|
||||||
|
title: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
latest_status: string
|
||||||
|
progress: number
|
||||||
|
is_completed: boolean
|
||||||
|
latest_session_uuid?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowLookup = {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface Role {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
organization: Organization
|
organization: Organization
|
||||||
|
members?: User[]
|
||||||
member_count?: number
|
member_count?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue'
|
import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue'
|
||||||
import { apiClient, API } from '../router/api'
|
import { apiClient, API } from '../router/api'
|
||||||
import type { Role } from '../types/organization'
|
import type { ProgressSessionApi, FlowLookup } from '../types/onboarding'
|
||||||
import type { ProgressSessionApi } from '../types/onboarding'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const roleId = computed(() => route.params.roleId as string)
|
const roleId = computed(() => route.params.roleId as string)
|
||||||
|
const selectedUserUuid = computed(() => String(route.query.user_uuid || ''))
|
||||||
|
const selectedFlowUuid = computed(() => String(route.query.flow_uuid || ''))
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const monitoring = ref(false)
|
const monitoring = ref(false)
|
||||||
const role = ref<Role | null>(null)
|
const roleName = ref('Unknown Role')
|
||||||
|
const learnerName = ref('Learner')
|
||||||
|
const flowTitle = ref('')
|
||||||
const sessions = ref<ProgressSessionApi[]>([])
|
const sessions = ref<ProgressSessionApi[]>([])
|
||||||
const feedback = ref<string>('')
|
const feedback = ref<string>('')
|
||||||
const monitorLogs = ref<string[]>([])
|
const monitorLogs = ref<string[]>([])
|
||||||
|
const monitorSocket = ref<WebSocket | null>(null)
|
||||||
|
const monitorTimeout = ref<number | null>(null)
|
||||||
|
|
||||||
const latestSession = computed(() => sessions.value[0] || null)
|
const latestSession = computed(() => sessions.value[0] || null)
|
||||||
|
|
||||||
|
|
@ -24,21 +29,44 @@ const websocketUrl = (id: string) => {
|
||||||
return `${protocol}://${window.location.host}/ws/onboarding/${id}/`
|
return `${protocol}://${window.location.host}/ws/onboarding/${id}/`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeMonitorSocket = () => {
|
||||||
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
if (monitorSocket.value) {
|
||||||
|
monitorSocket.value.close()
|
||||||
|
monitorSocket.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runProgressMonitor = async () => {
|
const runProgressMonitor = async () => {
|
||||||
monitoring.value = true
|
monitoring.value = true
|
||||||
monitorLogs.value = []
|
monitorLogs.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
closeMonitorSocket()
|
||||||
const ws = new WebSocket(websocketUrl(roleId.value))
|
const ws = new WebSocket(websocketUrl(roleId.value))
|
||||||
|
monitorSocket.value = ws
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = window.setTimeout(() => {
|
let settled = false
|
||||||
|
|
||||||
|
monitorTimeout.value = window.setTimeout(() => {
|
||||||
|
settled = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error('Progress monitor timed out'))
|
reject(new Error('Progress monitor timed out'))
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify({ action: 'progress_monitor', role_uuid: roleId.value }))
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: 'progress_monitor',
|
||||||
|
role_uuid: roleId.value,
|
||||||
|
user_uuid: selectedUserUuid.value || undefined,
|
||||||
|
flow_uuid: selectedFlowUuid.value || undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
|
@ -52,28 +80,55 @@ const runProgressMonitor = async () => {
|
||||||
feedback.value = String(
|
feedback.value = String(
|
||||||
payload.content?.feedback || payload.message || 'No feedback returned.',
|
payload.content?.feedback || payload.message || 'No feedback returned.',
|
||||||
)
|
)
|
||||||
window.clearTimeout(timeout)
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'error') {
|
if (payload.type === 'error') {
|
||||||
window.clearTimeout(timeout)
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(payload.message || 'Progress monitor failed'))
|
reject(new Error(payload.message || 'Progress monitor failed'))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
window.clearTimeout(timeout)
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error('Invalid monitor response'))
|
reject(new Error('Invalid monitor response'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
window.clearTimeout(timeout)
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error('Progress monitor websocket error'))
|
reject(new Error('Progress monitor websocket error'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (monitorTimeout.value !== null) {
|
||||||
|
window.clearTimeout(monitorTimeout.value)
|
||||||
|
monitorTimeout.value = null
|
||||||
|
}
|
||||||
|
monitorSocket.value = null
|
||||||
|
if (!settled) {
|
||||||
|
reject(new Error('Progress monitor connection closed before completion'))
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : 'Failed to run progress monitor')
|
message.error(error instanceof Error ? error.message : 'Failed to run progress monitor')
|
||||||
|
|
@ -85,21 +140,40 @@ const runProgressMonitor = async () => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rolesRes, sessionsRes] = await Promise.all([
|
const sessionsRes = await apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list(), {
|
||||||
apiClient.get<Role[]>(API.roles.mine()),
|
params: {
|
||||||
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
role_uuid: roleId.value,
|
||||||
])
|
user_uuid: selectedUserUuid.value || undefined,
|
||||||
|
flow_uuid: selectedFlowUuid.value || undefined,
|
||||||
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : []
|
},
|
||||||
role.value = roles.find((r) => r.uuid === roleId.value) || null
|
})
|
||||||
|
|
||||||
const allSessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : []
|
const allSessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : []
|
||||||
sessions.value = allSessions
|
sessions.value = allSessions.sort(
|
||||||
.filter((session) => session.role?.uuid === roleId.value)
|
(a, b) => new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(),
|
||||||
.sort(
|
)
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(),
|
if (sessions.value[0]?.role?.name) {
|
||||||
|
roleName.value = String(sessions.value[0].role.name)
|
||||||
|
}
|
||||||
|
if (sessions.value[0]?.user?.first_name || sessions.value[0]?.user?.last_name) {
|
||||||
|
const first = String(sessions.value[0].user.first_name || '')
|
||||||
|
const last = String(sessions.value[0].user.last_name || '')
|
||||||
|
learnerName.value = `${first} ${last}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessions.value.length) {
|
||||||
|
feedback.value = 'No onboarding session found for this learner and flow yet.'
|
||||||
|
monitorLogs.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFlowUuid.value) {
|
||||||
|
const flowRes = await apiClient.get<FlowLookup>(
|
||||||
|
API.onboarding.flows.byId(selectedFlowUuid.value),
|
||||||
)
|
)
|
||||||
|
flowTitle.value = String(flowRes.data?.title || '')
|
||||||
|
}
|
||||||
|
|
||||||
await runProgressMonitor()
|
await runProgressMonitor()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -112,6 +186,14 @@ const loadData = async () => {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
closeMonitorSocket()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
closeMonitorSocket()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -123,7 +205,11 @@ onMounted(() => {
|
||||||
|
|
||||||
<Card class="panel" :bordered="false">
|
<Card class="panel" :bordered="false">
|
||||||
<Spin :spinning="loading" tip="Loading role progress...">
|
<Spin :spinning="loading" tip="Loading role progress...">
|
||||||
<Typography.Title :level="4">{{ role?.name || 'Unknown Role' }}</Typography.Title>
|
<Typography.Title :level="4">{{ roleName }}</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
Learner: {{ learnerName }}
|
||||||
|
<span v-if="flowTitle"> | Flow: {{ flowTitle }}</span>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<Typography.Text strong>Latest Status:</Typography.Text>
|
<Typography.Text strong>Latest Status:</Typography.Text>
|
||||||
|
|
|
||||||
|
|
@ -3,107 +3,19 @@ import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Card, Typography, List, Tag, Button, Spin, message } from 'ant-design-vue'
|
import { Card, Typography, List, Tag, Button, Spin, message } from 'ant-design-vue'
|
||||||
import { apiClient, API } from '../router/api'
|
import { apiClient, API } from '../router/api'
|
||||||
import type { Role } from '../types/organization'
|
import type { ProgressOverviewItem } from '../types/onboarding'
|
||||||
import type { ProgressSessionApi, ProgressFlowApi, RoleProgressItem } from '../types/onboarding'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<RoleProgressItem[]>([])
|
const rows = ref<ProgressOverviewItem[]>([])
|
||||||
|
|
||||||
const websocketUrl = (roleId: string) => {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
||||||
return `${protocol}://${window.location.host}/ws/onboarding/${roleId}/`
|
|
||||||
}
|
|
||||||
|
|
||||||
const runProgressMonitor = (roleId: string): Promise<string> =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ws = new WebSocket(websocketUrl(roleId))
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
ws.close()
|
|
||||||
reject(new Error('Progress monitor timed out'))
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
action: 'progress_monitor',
|
|
||||||
role_uuid: roleId,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data)
|
|
||||||
if (payload.type === 'completed') {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
const feedback =
|
|
||||||
payload.content?.feedback || payload.message || 'No feedback returned.'
|
|
||||||
ws.close()
|
|
||||||
resolve(String(feedback))
|
|
||||||
} else if (payload.type === 'error') {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
ws.close()
|
|
||||||
reject(new Error(payload.message || 'Progress monitor failed'))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
ws.close()
|
|
||||||
reject(new Error('Invalid monitor response'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
ws.close()
|
|
||||||
reject(new Error('Progress monitor websocket error'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadProgress = async () => {
|
const loadProgress = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rolesRes, sessionsRes, flowsRes] = await Promise.all([
|
const overviewRes = await apiClient.get<ProgressOverviewItem[]>(
|
||||||
apiClient.get<Role[]>(API.roles.mine()),
|
API.onboarding.sessions.progressOverview(),
|
||||||
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()),
|
)
|
||||||
apiClient.get<ProgressFlowApi[]>(API.onboarding.flows.list()),
|
rows.value = Array.isArray(overviewRes.data) ? overviewRes.data : []
|
||||||
])
|
|
||||||
|
|
||||||
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : []
|
|
||||||
const sessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : []
|
|
||||||
const flows = Array.isArray(flowsRes.data) ? flowsRes.data : []
|
|
||||||
|
|
||||||
rows.value = roles.map((role) => {
|
|
||||||
const roleSessions = sessions
|
|
||||||
.filter((session) => session.role?.uuid === role.uuid)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updated_at || 0).getTime() -
|
|
||||||
new Date(a.updated_at || 0).getTime(),
|
|
||||||
)
|
|
||||||
const latestSession = roleSessions[0]
|
|
||||||
const flow = flows.find((f) => f.role?.uuid === role.uuid)
|
|
||||||
|
|
||||||
return {
|
|
||||||
role,
|
|
||||||
latestStatus: latestSession?.status || 'not_started',
|
|
||||||
latestSessionUuid: latestSession?.uuid,
|
|
||||||
flowTitle: flow?.title,
|
|
||||||
feedback: undefined,
|
|
||||||
loadingFeedback: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const row of rows.value) {
|
|
||||||
try {
|
|
||||||
row.feedback = await runProgressMonitor(row.role.uuid)
|
|
||||||
} catch (error) {
|
|
||||||
row.feedback =
|
|
||||||
error instanceof Error ? error.message : 'Unable to fetch monitor feedback.'
|
|
||||||
} finally {
|
|
||||||
row.loadingFeedback = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to load progress overview')
|
message.error('Failed to load progress overview')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -129,39 +41,40 @@ onMounted(() => {
|
||||||
<template #renderItem="{ item }">
|
<template #renderItem="{ item }">
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
:title="item.role.name"
|
:title="`${item.role.name} · ${item.user.name}`"
|
||||||
:description="item.flowTitle || 'No active flow yet'"
|
:description="item.flow.title"
|
||||||
/>
|
/>
|
||||||
<div class="row-meta">
|
<div class="row-meta">
|
||||||
<Tag
|
<Tag
|
||||||
:color="
|
:color="
|
||||||
item.latestStatus === 'completed'
|
item.latest_status === 'completed'
|
||||||
? 'green'
|
? 'green'
|
||||||
: item.latestStatus === 'active'
|
: item.latest_status === 'active'
|
||||||
? 'blue'
|
? 'blue'
|
||||||
: 'default'
|
: 'default'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ item.latestStatus }}
|
{{ item.latest_status }}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="gold">{{ item.progress }}%</Tag>
|
||||||
|
<Tag :color="item.flow.is_active ? 'cyan' : 'default'">
|
||||||
|
{{ item.flow.is_active ? 'active flow' : 'inactive flow' }}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="router.push(`/progress/${item.role.uuid}`)"
|
@click="
|
||||||
|
router.push({
|
||||||
|
path: `/progress/${item.role.uuid}`,
|
||||||
|
query: {
|
||||||
|
user_uuid: item.user.uuid,
|
||||||
|
flow_uuid: item.flow.uuid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="feedback-block">
|
|
||||||
<Typography.Text strong>Progress Monitor:</Typography.Text>
|
|
||||||
<Spin
|
|
||||||
v-if="item.loadingFeedback"
|
|
||||||
size="small"
|
|
||||||
style="margin-left: 0.5rem"
|
|
||||||
/>
|
|
||||||
<Typography.Paragraph v-else class="feedback-text">
|
|
||||||
{{ item.feedback || 'No feedback yet.' }}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</template>
|
</template>
|
||||||
</List>
|
</List>
|
||||||
|
|
@ -183,15 +96,8 @@ onMounted(() => {
|
||||||
.row-meta {
|
.row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.feedback-block {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
.feedback-text {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
color: #6b7280;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue