mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 10:05:50 +08:00
Add session timeline logging and admin sessions view
This commit is contained in:
107
server/api/admin/logs/sessions.get.ts
Normal file
107
server/api/admin/logs/sessions.get.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { TransmissionLog } from '../../../models/TransmissionLog'
|
||||
|
||||
interface SessionSummaryEntry {
|
||||
sessionId: string
|
||||
startedAt: string | null
|
||||
updatedAt: string | null
|
||||
entryCount: number
|
||||
callsign?: string
|
||||
lastMessage?: {
|
||||
text: string
|
||||
role: string
|
||||
channel: string
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
function toISO(value: any): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function extractCallsign(entry: any): string | undefined {
|
||||
const fromMetadata = (payload: any) =>
|
||||
payload?.metadata?.context?.variables?.callsign ||
|
||||
payload?.metadata?.context?.variables?.CALLSIGN ||
|
||||
payload?.metadata?.context?.variables?.Callsign
|
||||
|
||||
return (
|
||||
fromMetadata(entry.lastEntry) ||
|
||||
fromMetadata(entry.firstEntry) ||
|
||||
entry.lastEntry?.metadata?.variables?.callsign ||
|
||||
entry.firstEntry?.metadata?.variables?.callsign ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
|
||||
const pageSizeRaw = parseInt(String(query.pageSize ?? query.limit ?? '20'), 10)
|
||||
const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100)
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const matchStage = { sessionId: { $exists: true, $nin: [null, ''] } }
|
||||
|
||||
const [sessionDocs, totalCountAgg] = await Promise.all([
|
||||
TransmissionLog.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { sessionId: 1, createdAt: 1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$sessionId',
|
||||
firstEntry: { $first: '$$ROOT' },
|
||||
lastEntry: { $last: '$$ROOT' },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { 'lastEntry.createdAt': -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: pageSize },
|
||||
]).exec(),
|
||||
TransmissionLog.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $group: { _id: '$sessionId' } },
|
||||
{ $count: 'count' },
|
||||
]).exec(),
|
||||
])
|
||||
|
||||
const total = totalCountAgg?.[0]?.count || 0
|
||||
|
||||
const items: SessionSummaryEntry[] = sessionDocs.map((entry: any) => {
|
||||
const first = entry.firstEntry || {}
|
||||
const last = entry.lastEntry || {}
|
||||
return {
|
||||
sessionId: entry._id,
|
||||
startedAt: toISO(first.createdAt),
|
||||
updatedAt: toISO(last.createdAt),
|
||||
entryCount: entry.count || 0,
|
||||
callsign: extractCallsign(entry),
|
||||
lastMessage: last.text
|
||||
? {
|
||||
text: last.text,
|
||||
role: last.role,
|
||||
channel: last.channel,
|
||||
createdAt: toISO(last.createdAt) || new Date().toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pages: Math.ceil(total / pageSize) || 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
85
server/api/admin/logs/sessions/[sessionId].get.ts
Normal file
85
server/api/admin/logs/sessions/[sessionId].get.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { TransmissionLog } from '../../../../models/TransmissionLog'
|
||||
|
||||
function toISO(value: any): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function mapEntry(doc: any) {
|
||||
return {
|
||||
id: String(doc._id),
|
||||
role: doc.role,
|
||||
channel: doc.channel,
|
||||
direction: doc.direction,
|
||||
text: doc.text,
|
||||
normalized: doc.normalized || undefined,
|
||||
createdAt: toISO(doc.createdAt) || new Date().toISOString(),
|
||||
metadata: doc.metadata || undefined,
|
||||
sessionId: doc.sessionId || undefined,
|
||||
user: doc.user
|
||||
? {
|
||||
id: String(doc.user._id),
|
||||
email: doc.user.email,
|
||||
name: doc.user.name || undefined,
|
||||
role: doc.user.role,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function extractCallsign(entries: any[]): string | undefined {
|
||||
for (const entry of [...entries].reverse()) {
|
||||
const callsign =
|
||||
entry.metadata?.context?.variables?.callsign ||
|
||||
entry.metadata?.context?.variables?.CALLSIGN ||
|
||||
entry.metadata?.variables?.callsign
|
||||
if (callsign) {
|
||||
return callsign
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const sessionId = event.context.params?.sessionId
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Session ID is required' })
|
||||
}
|
||||
|
||||
const items = await TransmissionLog.find({ sessionId })
|
||||
.sort({ createdAt: 1 })
|
||||
.populate('user', 'email name role')
|
||||
.lean()
|
||||
.exec()
|
||||
|
||||
if (!items.length) {
|
||||
return {
|
||||
sessionId,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
entryCount: 0,
|
||||
callsign: undefined,
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = toISO(items[0].createdAt)
|
||||
const updatedAt = toISO(items[items.length - 1].createdAt)
|
||||
const callsign = extractCallsign(items)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
entryCount: items.length,
|
||||
callsign,
|
||||
entries: items.map(mapEntry),
|
||||
}
|
||||
})
|
||||
@@ -17,6 +17,7 @@ type TransmissionListItem = {
|
||||
createdAt: string
|
||||
metadata?: Record<string, any>
|
||||
user?: { id: string; email: string; name?: string; role: string }
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
function escapeRegExp(input: string) {
|
||||
@@ -33,6 +34,7 @@ function mapTransmission(doc: any): TransmissionListItem {
|
||||
normalized: doc.normalized || undefined,
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
|
||||
metadata: doc.metadata || undefined,
|
||||
sessionId: doc.sessionId || undefined,
|
||||
user: doc.user
|
||||
? {
|
||||
id: String(doc.user._id),
|
||||
|
||||
@@ -31,14 +31,8 @@ interface PTTRequest {
|
||||
interface PTTResponse {
|
||||
success: boolean;
|
||||
transcription: string;
|
||||
decision?: {
|
||||
next_state: string;
|
||||
controller_say_tpl?: string;
|
||||
off_schema?: boolean;
|
||||
radio_check?: boolean;
|
||||
activate_flow?: string;
|
||||
resume_previous?: boolean;
|
||||
};
|
||||
decision?: LLMDecisionResult['decision'];
|
||||
trace?: LLMDecisionResult['trace'];
|
||||
}
|
||||
|
||||
async function sh(cmd: string, args: string[]) {
|
||||
@@ -228,6 +222,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
flow: candidate.flow || undefined,
|
||||
state: candidateState
|
||||
};
|
||||
})
|
||||
@@ -235,12 +230,17 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state);
|
||||
|
||||
const sessionId = typeof body.context?.flags?.session_id === 'string'
|
||||
? body.context.flags.session_id
|
||||
: undefined;
|
||||
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
role: "pilot",
|
||||
channel: "ptt",
|
||||
direction: "incoming",
|
||||
text: transcribedText,
|
||||
sessionId,
|
||||
metadata: {
|
||||
moduleId: body.moduleId,
|
||||
lessonId: body.lessonId,
|
||||
@@ -270,6 +270,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (decision) {
|
||||
result.decision = decision;
|
||||
}
|
||||
if (decisionResult?.trace) {
|
||||
result.trace = decisionResult.trace;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
|
||||
@@ -123,10 +123,16 @@ export default defineEventHandler(async (event) => {
|
||||
lessonId?: string;
|
||||
tag?: string;
|
||||
format?: AudioFmt | "smallest";
|
||||
sessionId?: string;
|
||||
}>(event);
|
||||
|
||||
const user = await requireUserSession(event);
|
||||
|
||||
const rawSessionId = typeof body?.sessionId === "string"
|
||||
? body.sessionId.trim()
|
||||
: "";
|
||||
const sessionId = rawSessionId.length ? rawSessionId : undefined;
|
||||
|
||||
const raw = (body?.text || "").trim();
|
||||
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
||||
|
||||
@@ -226,6 +232,7 @@ export default defineEventHandler(async (event) => {
|
||||
direction: "outgoing",
|
||||
text: raw,
|
||||
normalized,
|
||||
sessionId,
|
||||
metadata: {
|
||||
level,
|
||||
voice,
|
||||
|
||||
@@ -43,6 +43,9 @@ export default defineEventHandler(async (event) => {
|
||||
.filter((phase: string) => phase.length)
|
||||
: []
|
||||
|
||||
const entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
|
||||
const isMain = body.isMain === true
|
||||
|
||||
const endStates = Array.isArray(body.endStates)
|
||||
? body.endStates
|
||||
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
|
||||
@@ -62,10 +65,19 @@ export default defineEventHandler(async (event) => {
|
||||
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
|
||||
roles,
|
||||
phases,
|
||||
entryMode,
|
||||
isMain,
|
||||
})
|
||||
|
||||
await flow.save()
|
||||
|
||||
if (isMain) {
|
||||
await DecisionFlow.updateMany(
|
||||
{ _id: { $ne: flow._id } },
|
||||
{ $set: { isMain: false } }
|
||||
)
|
||||
}
|
||||
|
||||
const { flow: serialized } = await getFlowWithNodes(slug)
|
||||
return serialized
|
||||
})
|
||||
|
||||
@@ -59,6 +59,14 @@ export default defineEventHandler(async (event) => {
|
||||
flow.phases = phases
|
||||
}
|
||||
|
||||
if (typeof body.entryMode === 'string') {
|
||||
flow.entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
|
||||
}
|
||||
|
||||
if (typeof body.isMain === 'boolean') {
|
||||
flow.isMain = body.isMain
|
||||
}
|
||||
|
||||
if (body.variables && typeof body.variables === 'object') {
|
||||
flow.variables = body.variables
|
||||
flow.markModified('variables')
|
||||
@@ -145,6 +153,13 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
await flow.save()
|
||||
|
||||
if (flow.isMain) {
|
||||
await DecisionFlow.updateMany(
|
||||
{ _id: { $ne: flow._id } },
|
||||
{ $set: { isMain: false } }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await getFlowWithNodes(slug)
|
||||
return data
|
||||
})
|
||||
|
||||
@@ -13,9 +13,9 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { decision, trace } = await routeDecision(body)
|
||||
const result = await routeDecision(body)
|
||||
const { decision, trace } = result
|
||||
|
||||
// Log for debugging when off-schema or radio check triggers
|
||||
if (decision.off_schema) {
|
||||
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)')
|
||||
}
|
||||
|
||||
return decision
|
||||
return result
|
||||
} catch (err: any) {
|
||||
console.error('Router failed:', err)
|
||||
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })
|
||||
|
||||
Reference in New Issue
Block a user