feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
FROM node:24-alpine AS build
|
||||
ARG NEXUS_VERSION=dev
|
||||
ARG NEXUS_GIT_SHA=unknown
|
||||
ENV VITE_BUILD_VERSION=${NEXUS_VERSION}
|
||||
ENV VITE_BUILD_SHA=${NEXUS_GIT_SHA}
|
||||
ENV VITE_BROWSER_TELEMETRY_ENABLED=true
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
@@ -131,6 +131,33 @@ test.describe('authenticated route and deep-link smoke', () => {
|
||||
})).toBeVisible()
|
||||
|
||||
await expect.poll(() => api.openClawOverviewRequestCount).toBe(1)
|
||||
expect(api.browserTelemetryRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test('distinguishes a dependency outage and recovers without hiding the route', async ({ page }, testInfo) => {
|
||||
await mockNexusApi(page, {
|
||||
role: 'owner',
|
||||
apiFailure: {
|
||||
path: '/api/v1/projects',
|
||||
status: 503,
|
||||
// Vue Query performs two bounded retries for safe reads. The visible
|
||||
// recovery action is the next, explicit request.
|
||||
attempts: 3,
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto('/projects')
|
||||
const recovery = page.locator('[data-state="offline"][data-problem-kind="offline"]')
|
||||
await expect(recovery).toBeVisible()
|
||||
await expect(recovery).toContainText('Abhängigkeit nicht erreichbar')
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('dependency-recovery.png'),
|
||||
fullPage: true,
|
||||
})
|
||||
await recovery.getByRole('button', { name: 'Erneut prüfen' }).click()
|
||||
|
||||
await expect(page.getByRole('link', { name: /Release Readiness/ })).toBeVisible()
|
||||
await expect(recovery).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => {
|
||||
|
||||
@@ -23,6 +23,11 @@ interface MockOptions {
|
||||
domainResyncSequence?: number
|
||||
boardRefreshDelayMs?: number
|
||||
boardRefreshTitle?: string
|
||||
apiFailure?: {
|
||||
path: string
|
||||
status: number
|
||||
attempts?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedRequest {
|
||||
@@ -40,6 +45,7 @@ export interface NexusApiHarness {
|
||||
readonly chatRequests: CapturedRequest[]
|
||||
readonly resyncResponseCount: number
|
||||
readonly openClawOverviewRequestCount: number
|
||||
readonly browserTelemetryRequestCount: number
|
||||
readonly proposal: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -453,6 +459,8 @@ export async function mockNexusApi(
|
||||
let resyncResponseCount = 0
|
||||
let boardInitialRequestCount = 0
|
||||
let openClawOverviewRequestCount = 0
|
||||
let browserTelemetryRequestCount = 0
|
||||
let injectedFailureCount = 0
|
||||
let openTaskState = 'Backlog'
|
||||
|
||||
await page.route('**/api/**', async route => {
|
||||
@@ -469,6 +477,23 @@ export async function mockNexusApi(
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
options.apiFailure
|
||||
&& path === options.apiFailure.path
|
||||
&& injectedFailureCount < (options.apiFailure.attempts ?? 1)
|
||||
) {
|
||||
injectedFailureCount += 1
|
||||
await fulfillJson(
|
||||
route,
|
||||
problem(
|
||||
'The requested Nexus dependency is temporarily unavailable.',
|
||||
options.apiFailure.status,
|
||||
),
|
||||
options.apiFailure.status,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/v1/auth/refresh' && method === 'POST') {
|
||||
if (!authenticated) {
|
||||
await fulfillJson(route, problem('No active E2E session.', 401), 401)
|
||||
@@ -861,6 +886,7 @@ export async function mockNexusApi(
|
||||
return
|
||||
}
|
||||
if (path === '/api/v1/telemetry/browser') {
|
||||
browserTelemetryRequestCount += 1
|
||||
await route.fulfill({ status: 202 })
|
||||
return
|
||||
}
|
||||
@@ -1192,6 +1218,9 @@ export async function mockNexusApi(
|
||||
get openClawOverviewRequestCount() {
|
||||
return openClawOverviewRequestCount
|
||||
},
|
||||
get browserTelemetryRequestCount() {
|
||||
return browserTelemetryRequestCount
|
||||
},
|
||||
get proposal() {
|
||||
return proposal
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nexus-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.60",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -33,6 +33,9 @@ export default defineConfig({
|
||||
webServer: {
|
||||
command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
url: baseURL,
|
||||
env: {
|
||||
VITE_BROWSER_TELEMETRY_ENABLED: 'false',
|
||||
},
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { components } from './generated/schema'
|
||||
import { apiClient } from './client'
|
||||
import { ApiProblem, type ProblemDetailsDto } from './contracts'
|
||||
import { ApiProblem, asProblemDetails } from './contracts'
|
||||
import { queryKeys } from './queryClient'
|
||||
import { createMutationRequestContext } from '../services/mutationContext'
|
||||
|
||||
@@ -73,12 +73,8 @@ const STATUS_META: Record<string, AgentProposalStatusMeta> = {
|
||||
},
|
||||
}
|
||||
|
||||
function asProblem(value: unknown): ProblemDetailsDto | null {
|
||||
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
|
||||
}
|
||||
|
||||
function fail(response: Response, error: unknown, fallback: string): never {
|
||||
throw new ApiProblem(response.status, asProblem(error), fallback)
|
||||
throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback)
|
||||
}
|
||||
|
||||
export function getAgentProposalStatusMeta(status: string): AgentProposalStatusMeta {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { components } from './generated/schema'
|
||||
|
||||
export type EntityType =
|
||||
| 'activity'
|
||||
| 'agent'
|
||||
@@ -57,37 +59,171 @@ export function normalizeOperationResult(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProblemDetailsDto {
|
||||
type?: string
|
||||
title?: string
|
||||
export type ProblemDetailsDto = Omit<
|
||||
components['schemas']['ProblemDetails'],
|
||||
| 'status'
|
||||
| 'code'
|
||||
| 'traceId'
|
||||
| 'operationId'
|
||||
| 'currentRevision'
|
||||
| 'retryAfterSeconds'
|
||||
| 'remaining'
|
||||
> & {
|
||||
status?: number
|
||||
detail?: string
|
||||
instance?: string
|
||||
code?: string
|
||||
traceId?: string
|
||||
operationId?: string
|
||||
currentRevision?: number
|
||||
retryAfterSeconds?: number
|
||||
remaining?: number
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export class ApiProblem extends Error {
|
||||
export type AppProblemKind =
|
||||
| 'validation'
|
||||
| 'authentication'
|
||||
| 'permission'
|
||||
| 'conflict'
|
||||
| 'unsupported'
|
||||
| 'offline'
|
||||
| 'timeout'
|
||||
| 'rate-limit'
|
||||
| 'not-found'
|
||||
| 'internal'
|
||||
|
||||
export type AppRecoveryAction =
|
||||
| 'retry'
|
||||
| 'reauthenticate'
|
||||
| 'reload-conflict'
|
||||
| 'inspect-connection'
|
||||
| 'none'
|
||||
|
||||
export class AppProblem extends Error {
|
||||
readonly status: number
|
||||
readonly problem: ProblemDetailsDto | null
|
||||
readonly code: string
|
||||
readonly kind: AppProblemKind
|
||||
readonly traceId: string | null
|
||||
readonly operationId: string | null
|
||||
readonly currentRevision: number | null
|
||||
readonly retryAfterSeconds: number | null
|
||||
readonly recoveryAction: AppRecoveryAction
|
||||
|
||||
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
|
||||
super(problem?.detail || problem?.title || fallback)
|
||||
this.name = 'ApiProblem'
|
||||
this.name = 'AppProblem'
|
||||
this.status = status
|
||||
this.problem = problem
|
||||
this.code = problem?.code || codeForStatus(status)
|
||||
this.kind = kindForCode(this.code, status)
|
||||
this.traceId = problem?.traceId || null
|
||||
this.operationId = problem?.operationId || null
|
||||
this.currentRevision = finiteNumber(problem?.currentRevision)
|
||||
this.retryAfterSeconds = finiteNumber(problem?.retryAfterSeconds)
|
||||
this.recoveryAction = recoveryForKind(this.kind)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Prefer AppProblem. Kept while API modules migrate. */
|
||||
export class ApiProblem extends AppProblem {
|
||||
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
|
||||
super(status, problem, fallback)
|
||||
this.name = 'ApiProblem'
|
||||
}
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const normalized = Number(value)
|
||||
return Number.isFinite(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function codeForStatus(status: number): string {
|
||||
if (status === 400 || status === 422) return 'validation_failed'
|
||||
if (status === 401) return 'unauthenticated'
|
||||
if (status === 403) return 'forbidden'
|
||||
if (status === 404) return 'not_found'
|
||||
if (status === 409) return 'conflict'
|
||||
if (status === 429) return 'rate_limited'
|
||||
if (status === 501) return 'unsupported_capability'
|
||||
if (status === 502 || status === 503 || status === 0) return 'dependency_unavailable'
|
||||
if (status === 504) return 'timeout'
|
||||
return 'internal_error'
|
||||
}
|
||||
|
||||
function kindForCode(code: string, status: number): AppProblemKind {
|
||||
if (code === 'validation_failed') return 'validation'
|
||||
if (code === 'unauthenticated' || status === 401) return 'authentication'
|
||||
if (code === 'forbidden' || status === 403) return 'permission'
|
||||
if (code === 'conflict' || status === 409) return 'conflict'
|
||||
if (code === 'unsupported_capability' || status === 501) return 'unsupported'
|
||||
if (code === 'dependency_unavailable' || status === 0 || status === 502 || status === 503) return 'offline'
|
||||
if (code === 'timeout' || status === 504) return 'timeout'
|
||||
if (code === 'rate_limited' || status === 429) return 'rate-limit'
|
||||
if (code === 'not_found' || status === 404) return 'not-found'
|
||||
return status >= 500 ? 'internal' : 'validation'
|
||||
}
|
||||
|
||||
function recoveryForKind(kind: AppProblemKind): AppRecoveryAction {
|
||||
if (kind === 'authentication') return 'reauthenticate'
|
||||
if (kind === 'conflict') return 'reload-conflict'
|
||||
if (kind === 'offline' || kind === 'unsupported') return 'inspect-connection'
|
||||
if (kind === 'permission' || kind === 'not-found') return 'none'
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
export function asProblemDetails(value: unknown, status?: number): ProblemDetailsDto | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
|
||||
const source = value as Record<string, unknown>
|
||||
const detail = typeof source.detail === 'string'
|
||||
? source.detail
|
||||
: typeof source.message === 'string'
|
||||
? source.message
|
||||
: typeof source.error === 'string'
|
||||
? source.error
|
||||
: undefined
|
||||
const normalizedStatus = finiteNumber(source.status) ?? status
|
||||
|
||||
return {
|
||||
...(source as ProblemDetailsDto),
|
||||
status: normalizedStatus ?? undefined,
|
||||
detail,
|
||||
code: typeof source.code === 'string' ? source.code : undefined,
|
||||
traceId: typeof source.traceId === 'string' ? source.traceId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function toAppProblem(error: unknown, fallback = 'Die Anfrage ist fehlgeschlagen.'): AppProblem {
|
||||
if (error instanceof AppProblem) return error
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return new AppProblem(504, {
|
||||
code: 'timeout',
|
||||
detail: 'Die Anfrage wurde abgebrochen oder hat ihr Zeitlimit überschritten.',
|
||||
}, fallback)
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return new AppProblem(0, {
|
||||
code: 'dependency_unavailable',
|
||||
detail: error.message || 'Nexus konnte den Dienst nicht erreichen.',
|
||||
}, fallback)
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return new AppProblem(500, {
|
||||
code: 'internal_error',
|
||||
detail: error.message,
|
||||
}, fallback)
|
||||
}
|
||||
return new AppProblem(500, { code: 'internal_error', detail: fallback }, fallback)
|
||||
}
|
||||
|
||||
export async function throwApiProblem(response: Response, fallback: string): Promise<never> {
|
||||
let problem: ProblemDetailsDto | null = null
|
||||
try {
|
||||
problem = await response.json() as ProblemDetailsDto
|
||||
problem = asProblemDetails(await response.json(), response.status)
|
||||
} catch {
|
||||
// A structured ProblemDetails response is preferred, but legacy endpoints
|
||||
// can still return an empty error body during the migration.
|
||||
}
|
||||
throw new ApiProblem(response.status, problem, fallback)
|
||||
}
|
||||
import type { components } from './generated/schema'
|
||||
|
||||
+90
-33
@@ -518,39 +518,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/csrf": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2767,6 +2734,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/health/ready": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Service Unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8207,6 +8214,31 @@ export interface components {
|
||||
status?: null | number | string;
|
||||
detail?: null | string;
|
||||
instance?: null | string;
|
||||
/** @description Stable machine-readable Nexus error code. */
|
||||
code: null | string;
|
||||
/** @description Privacy-safe server trace identifier. */
|
||||
traceId: null | string;
|
||||
/** @description Durable operation identifier when the request started an operation. */
|
||||
operationId?: null | string;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Current server revision for a stale-write conflict.
|
||||
*/
|
||||
currentRevision?: null | number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Minimum retry delay advertised by Nexus.
|
||||
*/
|
||||
retryAfterSeconds?: null | number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Remaining attempts when a bounded policy exposes that value.
|
||||
*/
|
||||
remaining?: null | number;
|
||||
/** @description Client-supplied content hash for a stale-write conflict. */
|
||||
expectedHash?: null | string;
|
||||
/** @description Current server content hash for a stale-write conflict. */
|
||||
currentHash?: null | string;
|
||||
};
|
||||
ProjectDto: {
|
||||
/** Format: uuid */
|
||||
@@ -8422,6 +8454,31 @@ export interface components {
|
||||
errors?: {
|
||||
[key: string]: string[];
|
||||
};
|
||||
/** @description Stable machine-readable Nexus error code. */
|
||||
code: null | string;
|
||||
/** @description Privacy-safe server trace identifier. */
|
||||
traceId: null | string;
|
||||
/** @description Durable operation identifier when the request started an operation. */
|
||||
operationId?: null | string;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Current server revision for a stale-write conflict.
|
||||
*/
|
||||
currentRevision?: null | number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Minimum retry delay advertised by Nexus.
|
||||
*/
|
||||
retryAfterSeconds?: null | number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Remaining attempts when a bounded policy exposes that value.
|
||||
*/
|
||||
remaining?: null | number;
|
||||
/** @description Client-supplied content hash for a stale-write conflict. */
|
||||
expectedHash?: null | string;
|
||||
/** @description Current server content hash for a stale-write conflict. */
|
||||
currentHash?: null | string;
|
||||
};
|
||||
VerifyOpenClawRequest: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { paths } from './generated/schema'
|
||||
import { apiClient } from './client'
|
||||
import { ApiProblem, type ProblemDetailsDto } from './contracts'
|
||||
import { ApiProblem, asProblemDetails } from './contracts'
|
||||
import { queryClient, queryKeys } from './queryClient'
|
||||
import type { AgentNodeData } from '../composables/useFlowLayout'
|
||||
|
||||
@@ -27,12 +27,8 @@ export type OpenClawCapabilitiesDto = GeneratedCapabilities
|
||||
export type OpenClawAgentsDto = GeneratedAgents
|
||||
type OpenClawAgentDto = OpenClawAgentsDto['items'][number]
|
||||
|
||||
function asProblem(value: unknown): ProblemDetailsDto | null {
|
||||
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
|
||||
}
|
||||
|
||||
function fail(response: Response, error: unknown, fallback: string): never {
|
||||
throw new ApiProblem(response.status, asProblem(error), fallback)
|
||||
throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback)
|
||||
}
|
||||
|
||||
export async function fetchOpenClawOverview(
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { ListTodo } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { TaskItem } from './types'
|
||||
import AsyncStatePanel from '../../mission-control/AsyncStatePanel.vue'
|
||||
|
||||
defineProps<{
|
||||
tasks: TaskItem[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
error?: unknown
|
||||
}>()
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
|
||||
function priorityLabel(priority: TaskItem['priority']): string {
|
||||
return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2'
|
||||
}
|
||||
@@ -37,14 +40,35 @@ function taskLabel(task: TaskItem): string {
|
||||
<span>Fokus</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="task-list" aria-label="Tasks werden geladen">
|
||||
<span v-for="index in 3" :key="index" class="tcard skeleton"></span>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !tasks.length"
|
||||
class="tstrip-state"
|
||||
state="loading"
|
||||
title="Fokus-Tasks werden geladen"
|
||||
compact
|
||||
inline
|
||||
/>
|
||||
|
||||
<div v-else-if="error" class="tstrip-msg error">{{ error }}</div>
|
||||
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="error && !tasks.length"
|
||||
class="tstrip-state"
|
||||
state="error"
|
||||
title="Fokus-Tasks nicht verfügbar"
|
||||
:problem="error"
|
||||
compact
|
||||
inline
|
||||
@action="$emit('retry')"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="!tasks.length"
|
||||
class="tstrip-state"
|
||||
state="empty"
|
||||
title="Keine aktiven Tasks"
|
||||
compact
|
||||
inline
|
||||
/>
|
||||
|
||||
<div v-else class="task-list">
|
||||
<div v-else class="task-list" :aria-busy="loading">
|
||||
<RouterLink
|
||||
v-for="task in tasks.slice(0, 4)"
|
||||
:key="task.id"
|
||||
@@ -102,6 +126,11 @@ function taskLabel(task: TaskItem): string {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tstrip-state {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tcard {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CircleOff,
|
||||
CloudOff,
|
||||
FileQuestion,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from '@lucide/vue'
|
||||
import { toAppProblem, type AppProblem } from '../../api/contracts'
|
||||
|
||||
export type AsyncStateKind = 'loading' | 'empty' | 'error' | 'offline' | 'stale' | 'partial'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
state: AsyncStateKind
|
||||
title?: string
|
||||
message?: string
|
||||
problem?: unknown
|
||||
actionLabel?: string
|
||||
busy?: boolean
|
||||
compact?: boolean
|
||||
inline?: boolean
|
||||
}>(), {
|
||||
title: '',
|
||||
message: '',
|
||||
problem: undefined,
|
||||
actionLabel: '',
|
||||
busy: false,
|
||||
compact: false,
|
||||
inline: false,
|
||||
})
|
||||
|
||||
defineEmits<{ action: [] }>()
|
||||
|
||||
const normalizedProblem = computed<AppProblem | null>(() =>
|
||||
props.problem === undefined ? null : toAppProblem(props.problem),
|
||||
)
|
||||
|
||||
const resolvedState = computed<AsyncStateKind>(() =>
|
||||
props.state === 'error' && normalizedProblem.value?.kind === 'offline'
|
||||
? 'offline'
|
||||
: props.state,
|
||||
)
|
||||
|
||||
const problemLabel = computed(() => {
|
||||
const kind = normalizedProblem.value?.kind
|
||||
if (kind === 'authentication') return 'Sitzung abgelaufen'
|
||||
if (kind === 'permission') return 'Keine Berechtigung'
|
||||
if (kind === 'conflict') return 'Versionskonflikt'
|
||||
if (kind === 'unsupported') return 'Capability fehlt'
|
||||
if (kind === 'offline') return 'Abhängigkeit nicht erreichbar'
|
||||
if (kind === 'timeout') return 'Zeitüberschreitung'
|
||||
if (kind === 'rate-limit') return 'Rate Limit'
|
||||
if (kind === 'not-found') return 'Nicht gefunden'
|
||||
if (kind === 'validation') return 'Eingabe ungültig'
|
||||
if (kind === 'internal') return 'Interner Fehler'
|
||||
return ''
|
||||
})
|
||||
|
||||
const presentation = computed(() => {
|
||||
const problem = normalizedProblem.value
|
||||
const defaults = {
|
||||
loading: ['Daten werden geladen', 'Nexus synchronisiert den aktuellen Stand.'],
|
||||
empty: ['Noch keine Daten', 'Für diesen Bereich liegen derzeit keine Einträge vor.'],
|
||||
error: ['Daten konnten nicht geladen werden', problem?.message || 'Die Anfrage ist fehlgeschlagen.'],
|
||||
offline: ['Verbindung nicht verfügbar', problem?.message || 'Nexus kann den abhängigen Dienst derzeit nicht erreichen.'],
|
||||
stale: ['Daten möglicherweise veraltet', 'Die letzte bestätigte Momentaufnahme bleibt sichtbar.'],
|
||||
partial: ['Daten teilweise verfügbar', 'Nexus zeigt den bestätigten Teilbestand und hält fehlende Bereiche sichtbar.'],
|
||||
} satisfies Record<AsyncStateKind, [string, string]>
|
||||
|
||||
return {
|
||||
title: props.title || defaults[props.state][0],
|
||||
message: props.message || defaults[props.state][1],
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedActionLabel = computed(() => {
|
||||
if (props.actionLabel) return props.actionLabel
|
||||
const action = normalizedProblem.value?.recoveryAction
|
||||
if (action === 'reauthenticate') return 'Neu anmelden'
|
||||
if (action === 'reload-conflict') return 'Aktuellen Stand laden'
|
||||
if (action === 'inspect-connection') return 'Erneut prüfen'
|
||||
if (action === 'retry') return 'Erneut versuchen'
|
||||
return ''
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
if (resolvedState.value === 'loading') return Loader2
|
||||
if (resolvedState.value === 'empty') return FileQuestion
|
||||
if (resolvedState.value === 'offline') return CloudOff
|
||||
if (resolvedState.value === 'stale' || resolvedState.value === 'partial') return CircleOff
|
||||
return AlertTriangle
|
||||
})
|
||||
|
||||
const liveRole = computed(() =>
|
||||
resolvedState.value === 'error' || resolvedState.value === 'offline' ? 'alert' : 'status',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="async-state nexus-state"
|
||||
:class="[`nexus-state--${resolvedState}`, problemLabel ? `nexus-state--problem-${normalizedProblem?.kind}` : '', { 'async-state--compact': compact, 'async-state--inline': inline }]"
|
||||
:role="liveRole"
|
||||
:aria-busy="resolvedState === 'loading' || busy"
|
||||
:data-state="resolvedState"
|
||||
:data-problem-kind="normalizedProblem?.kind"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
:size="inline ? 14 : compact ? 18 : 22"
|
||||
:class="{ spin: resolvedState === 'loading' || busy }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="async-state__body">
|
||||
<span v-if="problemLabel" class="async-state__kind">{{ problemLabel }}</span>
|
||||
<strong>{{ presentation.title }}</strong>
|
||||
<p>{{ presentation.message }}</p>
|
||||
<details v-if="normalizedProblem?.traceId || normalizedProblem?.operationId" class="async-state__details">
|
||||
<summary>Technische Details</summary>
|
||||
<span v-if="normalizedProblem.traceId">Trace {{ normalizedProblem.traceId }}</span>
|
||||
<span v-if="normalizedProblem.operationId">Operation {{ normalizedProblem.operationId }}</span>
|
||||
</details>
|
||||
</div>
|
||||
<button
|
||||
v-if="resolvedActionLabel"
|
||||
type="button"
|
||||
class="nexus-button"
|
||||
:disabled="busy"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
<Loader2 v-if="busy" :size="14" class="spin" aria-hidden="true" />
|
||||
<RefreshCw v-else :size="14" aria-hidden="true" />
|
||||
{{ resolvedActionLabel }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.async-state {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.async-state--compact {
|
||||
min-height: 72px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.async-state--inline {
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.async-state--inline .async-state__body strong {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-body, 'Manrope', sans-serif);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.async-state--inline .async-state__body p,
|
||||
.async-state--inline .async-state__details,
|
||||
.async-state--inline .async-state__kind {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.async-state--inline .nexus-button {
|
||||
min-height: 24px;
|
||||
padding: 3px 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.async-state__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.async-state__body strong {
|
||||
display: block;
|
||||
color: var(--tx);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.async-state__kind {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--tx-3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.async-state__body p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--tx-2);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.async-state__details {
|
||||
margin-top: 8px;
|
||||
color: var(--tx-3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.async-state__details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.async-state__details span {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.async-state {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.async-state .nexus-button {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
let loginRedirectStarted = false
|
||||
|
||||
function redirectToLogin(): void {
|
||||
if (loginRedirectStarted || window.location.pathname === '/login') return
|
||||
loginRedirectStarted = true
|
||||
|
||||
const currentTarget = `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
const loginUrl = new URL('/login', window.location.origin)
|
||||
if (currentTarget !== '/') loginUrl.searchParams.set('redirect', currentTarget)
|
||||
window.location.assign(`${loginUrl.pathname}${loginUrl.search}`)
|
||||
}
|
||||
|
||||
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.initialized) await auth.initialize()
|
||||
@@ -28,7 +40,7 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
|
||||
|
||||
const refreshed = await auth.refresh()
|
||||
if (!refreshed) {
|
||||
if (window.location.pathname !== '/login') window.location.assign('/login')
|
||||
redirectToLogin()
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,17 @@ const STANDARD_METRICS = new Set<BrowserMetricName>(['CLS', 'FCP', 'INP', 'LCP',
|
||||
let activeRouter: Router | null = null
|
||||
let started = false
|
||||
|
||||
export function isBrowserTelemetryEnabled(): boolean {
|
||||
return String(import.meta.env.VITE_BROWSER_TELEMETRY_ENABLED ?? 'true').toLowerCase() !== 'false'
|
||||
}
|
||||
|
||||
function safeRouteName(): string {
|
||||
const name = activeRouter?.currentRoute.value.name
|
||||
return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown'
|
||||
}
|
||||
|
||||
async function report(envelope: BrowserMetricEnvelope): Promise<void> {
|
||||
if (safeRouteName() === 'Login') return
|
||||
if (!isBrowserTelemetryEnabled() || safeRouteName() === 'Login') return
|
||||
try {
|
||||
await apiFetch('/api/v1/telemetry/browser', {
|
||||
method: 'POST',
|
||||
@@ -53,7 +57,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
routeName: safeRouteName(),
|
||||
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
|
||||
buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
|
||||
navigationType: String(metric.navigationType || 'navigate').slice(0, 40),
|
||||
liveMode: 'unknown',
|
||||
correlationId: null,
|
||||
@@ -62,7 +66,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
|
||||
|
||||
export function startBrowserTelemetry(router: Router): void {
|
||||
activeRouter = router
|
||||
if (started || typeof window === 'undefined') return
|
||||
if (!isBrowserTelemetryEnabled() || started || typeof window === 'undefined') return
|
||||
started = true
|
||||
|
||||
const callback = (metric: Metric) => {
|
||||
@@ -91,7 +95,7 @@ export async function reportCustomMetric(
|
||||
value,
|
||||
rating: 'custom',
|
||||
routeName: safeRouteName(),
|
||||
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
|
||||
buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
|
||||
navigationType: 'spa',
|
||||
liveMode: options.liveMode ?? 'unknown',
|
||||
correlationId: options.correlationId?.slice(0, 128) ?? null,
|
||||
|
||||
@@ -108,16 +108,23 @@ export const useAuthStore = defineStore('auth', {
|
||||
const body = await response.json() as Record<string, unknown>
|
||||
if (typeof body.remaining === 'number') remaining = body.remaining
|
||||
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
|
||||
const problemMessage = typeof body.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body.message === 'string'
|
||||
? body.message
|
||||
: typeof body.title === 'string'
|
||||
? body.title
|
||||
: null
|
||||
|
||||
if (response.status === 429) {
|
||||
this.remainingAttempts = 0
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter)
|
||||
throw new LoginError(problemMessage || 'Too many attempts.', 0, retryAfter)
|
||||
} else if (response.status === 401) {
|
||||
this.remainingAttempts = remaining
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(
|
||||
body.message as string || 'Invalid email or password.',
|
||||
problemMessage || 'Invalid email or password.',
|
||||
remaining ?? 4,
|
||||
retryAfter,
|
||||
)
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import type { EntityRefDto } from '../api/contracts'
|
||||
import { useActivity } from '../api/activity'
|
||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||
import EntityLink from '../components/mission-control/EntityLink.vue'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
interface UnifiedActivity {
|
||||
id: string
|
||||
@@ -37,6 +38,7 @@ interface UnifiedActivity {
|
||||
const overviewQuery = useOpenClawOverviewQuery()
|
||||
const nexusActivity = useActivity()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const query = ref('')
|
||||
const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all')
|
||||
const severityFilter = ref('all')
|
||||
@@ -220,19 +222,15 @@ onUnmounted(() => {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
<AsyncStatePanel
|
||||
v-if="runtimeCollection && runtimeCollection.state !== 'ready'"
|
||||
class="runtime-state nexus-state"
|
||||
:class="{ 'nexus-state--error': runtimeCollection.state === 'disconnected' || runtimeCollection.state === 'error' }"
|
||||
>
|
||||
<ServerCog :size="18" aria-hidden="true" />
|
||||
<div>
|
||||
<h2>OpenClaw audit feed: {{ runtimeCollection.state }}</h2>
|
||||
<p>{{ runtimeCollection.message || 'Runtime activity is not currently available.' }}</p>
|
||||
<p v-if="runtimeCollection.recovery">{{ runtimeCollection.recovery }}</p>
|
||||
</div>
|
||||
<RouterLink class="nexus-button" to="/settings">Diagnostics</RouterLink>
|
||||
</section>
|
||||
:state="['disconnected', 'error'].includes(runtimeCollection.state) ? 'offline' : 'partial'"
|
||||
:title="`OpenClaw-Auditfeed: ${runtimeCollection.state}`"
|
||||
:message="runtimeCollection.recovery || runtimeCollection.message || 'Runtime-Aktivität ist derzeit nicht vollständig verfügbar.'"
|
||||
action-label="Diagnose öffnen"
|
||||
compact
|
||||
@action="router.push('/settings')"
|
||||
/>
|
||||
|
||||
<section class="activity-toolbar nexus-panel" aria-label="Activity filters">
|
||||
<label class="activity-search">
|
||||
@@ -258,10 +256,11 @@ onUnmounted(() => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value" class="nexus-state nexus-state--loading">
|
||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
||||
<p>Loading activity sources…</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value"
|
||||
state="loading"
|
||||
title="Aktivitätsquellen werden geladen"
|
||||
/>
|
||||
<section v-else-if="filteredEvents.length" class="timeline" aria-label="Activity timeline">
|
||||
<button
|
||||
v-for="event in filteredEvents"
|
||||
@@ -291,12 +290,12 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
<div v-else class="nexus-state nexus-state--empty">
|
||||
<Activity :size="18" aria-hidden="true" />
|
||||
<h2>No matching activity</h2>
|
||||
<p v-if="events.length">Adjust the search or source filters.</p>
|
||||
<p v-else>No Nexus or OpenClaw events have been returned yet.</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine passende Aktivität"
|
||||
:message="events.length ? 'Passe Suche oder Quellenfilter an.' : 'Nexus und OpenClaw haben noch keine Ereignisse geliefert.'"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedEvent" class="activity-dialog-overlay" @click.self="closeDetails">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type CreateAgentProposalRequest,
|
||||
} from '../api/agentProposals'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -137,15 +138,19 @@ async function submitProposal() {
|
||||
<span>Agent-Vorschläge enthalten OpenClaw-Konfiguration und sind deshalb ausschließlich für Owner sichtbar.</span>
|
||||
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="optionsQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
|
||||
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
|
||||
OpenClaw-Optionen werden geprüft…
|
||||
</div>
|
||||
<div v-else-if="optionsQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
|
||||
<strong>Optionen nicht verfügbar</strong>
|
||||
<span>{{ optionsQuery.error.value?.message || 'Die Create-Options konnten nicht geladen werden.' }}</span>
|
||||
<button type="button" class="nexus-button" @click="optionsQuery.refetch()">Erneut prüfen</button>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="optionsQuery.isPending.value"
|
||||
state="loading"
|
||||
title="OpenClaw-Optionen werden geprüft"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="optionsQuery.isError.value"
|
||||
state="error"
|
||||
title="Agent-Optionen nicht verfügbar"
|
||||
:problem="optionsQuery.error.value"
|
||||
action-label="Erneut prüfen"
|
||||
@action="optionsQuery.refetch()"
|
||||
/>
|
||||
|
||||
<template v-else-if="options">
|
||||
<section
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
|
||||
import { ArrowLeft, Bot, Activity, RefreshCw } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
import {
|
||||
applyAgentFileWrite,
|
||||
@@ -22,6 +22,7 @@ import StandingOrdersEditor from '../components/config/StandingOrdersEditor.vue'
|
||||
import { subscribeDomainEventState } from '../services/domainEvents'
|
||||
import { createMutationRequestContext } from '../services/mutationContext'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -74,15 +75,6 @@ const configFiles = computed(() => filesQuery.data.value?.files ?? [])
|
||||
const loading = computed(() => agentQuery.isPending.value)
|
||||
const activityLoading = computed(() => activityQuery.isFetching.value)
|
||||
const summaryLoading = computed(() => summaryQuery.isFetching.value)
|
||||
const error = computed(() =>
|
||||
agentQuery.error.value instanceof Error ? agentQuery.error.value.message : '',
|
||||
)
|
||||
const activityError = computed(() =>
|
||||
activityQuery.error.value instanceof Error ? activityQuery.error.value.message : '',
|
||||
)
|
||||
const summaryError = computed(() =>
|
||||
summaryQuery.error.value instanceof Error ? summaryQuery.error.value.message : '',
|
||||
)
|
||||
|
||||
const currentFile = computed(() => {
|
||||
if (!configFiles.value.length) return null
|
||||
@@ -116,10 +108,7 @@ const configsError = computed(() => {
|
||||
return cause instanceof Error ? cause.message : ''
|
||||
})
|
||||
const initLoading = computed(() =>
|
||||
loading.value
|
||||
|| activityQuery.isPending.value
|
||||
|| summaryQuery.isPending.value
|
||||
|| (canConfigure.value && filesQuery.isPending.value),
|
||||
loading.value,
|
||||
)
|
||||
|
||||
const fallbackName = computed(() => {
|
||||
@@ -185,6 +174,23 @@ function scheduleActivityReload() {
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function retryAgent() {
|
||||
void agentQuery.refetch()
|
||||
}
|
||||
|
||||
function retrySummary() {
|
||||
void summaryQuery.refetch()
|
||||
}
|
||||
|
||||
function retryActivity() {
|
||||
void activityQuery.refetch()
|
||||
}
|
||||
|
||||
function retryConfigs() {
|
||||
void filesQuery.refetch()
|
||||
if (activeTabFileName.value) void fileQuery.refetch()
|
||||
}
|
||||
|
||||
function formatSummaryTimestamp(value?: string | null): string {
|
||||
if (!value) return 'No timestamp'
|
||||
const d = new Date(value)
|
||||
@@ -316,12 +322,14 @@ async function saveFile() {
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
const problem = err as {
|
||||
detail?: string
|
||||
error?: string
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
currentHash?: string
|
||||
}
|
||||
const detail = problem.message
|
||||
const detail = problem.detail
|
||||
|| problem.message
|
||||
|| problem.error
|
||||
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
||||
|| 'Failed to save file'
|
||||
@@ -401,12 +409,30 @@ onUnmounted(() => {
|
||||
Zurück zu Agents
|
||||
</button>
|
||||
|
||||
<div v-if="initLoading" class="status-message">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading agent data...
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="initLoading && !agent"
|
||||
state="loading"
|
||||
title="Agent wird geladen"
|
||||
/>
|
||||
|
||||
<AsyncStatePanel
|
||||
v-else-if="agentQuery.error.value && !agent"
|
||||
state="error"
|
||||
title="Agent nicht verfügbar"
|
||||
:problem="agentQuery.error.value"
|
||||
@action="retryAgent"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<AsyncStatePanel
|
||||
v-if="agentQuery.error.value && agent"
|
||||
state="stale"
|
||||
title="Agentdaten möglicherweise veraltet"
|
||||
:problem="agentQuery.error.value"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryAgent"
|
||||
/>
|
||||
<!-- Agent header -->
|
||||
<div class="agent-header">
|
||||
<div class="agent-avatar" :class="agentId">
|
||||
@@ -426,12 +452,6 @@ onUnmounted(() => {
|
||||
{{ formatLastSeen(agent.lastSeen) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="error && !agent" class="agent-status-row">
|
||||
<span class="status-label muted">
|
||||
<AlertCircle :size="11" />
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -455,15 +475,21 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="summaryLoading && !summary" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading summaries...
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="summaryLoading && !summary"
|
||||
state="loading"
|
||||
title="Zusammenfassungen werden geladen"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div v-else-if="summaryError && !summary" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="summaryQuery.error.value && !summary"
|
||||
state="error"
|
||||
title="Zusammenfassungen nicht verfügbar"
|
||||
:problem="summaryQuery.error.value"
|
||||
compact
|
||||
@action="retrySummary"
|
||||
/>
|
||||
|
||||
<div v-else-if="summary" class="summary-row">
|
||||
<div class="summary-card">
|
||||
@@ -478,61 +504,115 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No summary available.
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine Zusammenfassung"
|
||||
message="OpenClaw hat für diesen Agenten noch keine bestätigte Zusammenfassung geliefert."
|
||||
compact
|
||||
/>
|
||||
|
||||
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
|
||||
<AlertCircle :size="14" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="summaryQuery.error.value && summary"
|
||||
state="stale"
|
||||
title="Zusammenfassung möglicherweise veraltet"
|
||||
:problem="summaryQuery.error.value"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retrySummary"
|
||||
/>
|
||||
|
||||
<div v-if="liveUnavailable" class="status-message compact">
|
||||
Live stream reconnecting…
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="liveUnavailable"
|
||||
state="stale"
|
||||
title="Live-Stream wird neu verbunden"
|
||||
message="Die letzte bestätigte Aktivität bleibt sichtbar."
|
||||
action-label="Manuell aktualisieren"
|
||||
compact
|
||||
@action="refreshActivity"
|
||||
/>
|
||||
|
||||
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading activity...
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="activityLoading && !activityItems.length"
|
||||
state="loading"
|
||||
title="Aktivität wird geladen"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div v-else-if="activityError" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ activityError }}
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="activityQuery.error.value && !activityItems.length"
|
||||
state="error"
|
||||
title="Aktivität nicht verfügbar"
|
||||
:problem="activityQuery.error.value"
|
||||
compact
|
||||
@action="retryActivity"
|
||||
/>
|
||||
|
||||
<div v-else-if="activityItems.length" class="thinking-list">
|
||||
<article
|
||||
v-for="item in activityItems"
|
||||
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
||||
class="thinking-item"
|
||||
>
|
||||
<div class="thinking-meta">
|
||||
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
|
||||
<span>{{ formatActivityTime(item) }}</span>
|
||||
</div>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<template v-else-if="activityItems.length">
|
||||
<AsyncStatePanel
|
||||
v-if="activityQuery.error.value"
|
||||
state="stale"
|
||||
title="Aktivitätsliste möglicherweise veraltet"
|
||||
:problem="activityQuery.error.value"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryActivity"
|
||||
/>
|
||||
<div class="thinking-list">
|
||||
<article
|
||||
v-for="item in activityItems"
|
||||
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
||||
class="thinking-item"
|
||||
>
|
||||
<div class="thinking-meta">
|
||||
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
|
||||
<span>{{ formatActivityTime(item) }}</span>
|
||||
</div>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No recent activity.
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine aktuelle Aktivität"
|
||||
message="Für diesen Agenten liegen noch keine bestätigten Events vor."
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Config section -->
|
||||
<div class="config-section">
|
||||
<div v-if="configsLoading" class="status-message">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading config files...
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="configsLoading && !configFiles.length"
|
||||
state="loading"
|
||||
title="Agent-Dateien werden geladen"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div v-else-if="configsError" class="status-message error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ configsError }}
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="configsError && !configFiles.length"
|
||||
state="error"
|
||||
title="Agent-Dateien nicht verfügbar"
|
||||
:message="configsError"
|
||||
:problem="fileQuery.error.value ?? filesQuery.error.value"
|
||||
:action-label="canConfigure ? 'Aktualisieren' : ''"
|
||||
compact
|
||||
@action="retryConfigs"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<AsyncStatePanel
|
||||
v-if="configsError"
|
||||
state="stale"
|
||||
title="Agent-Dateiliste möglicherweise veraltet"
|
||||
:message="configsError"
|
||||
:problem="fileQuery.error.value ?? filesQuery.error.value"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryConfigs"
|
||||
/>
|
||||
<ConfigTabs
|
||||
:tabs="configFiles.map(file => file.name)"
|
||||
:active-tab="activeTab"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type OperationResultDto,
|
||||
} from '../api/contracts'
|
||||
import OperationResultCard from '../components/mission-control/OperationResultCard.vue'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
type ActionMode = 'approve' | 'reject' | 'retry'
|
||||
|
||||
@@ -192,15 +193,18 @@ async function confirmAction() {
|
||||
<span>Agent-Vorschläge und Provisionierungsaktionen sind ausschließlich für Owner sichtbar.</span>
|
||||
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="proposalQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
|
||||
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
|
||||
Vorschlag wird geladen…
|
||||
</div>
|
||||
<div v-else-if="proposalQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
|
||||
<strong>Vorschlag nicht verfügbar</strong>
|
||||
<span>{{ proposalQuery.error.value?.message || 'Der Proposal-Vertrag konnte nicht gelesen werden.' }}</span>
|
||||
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="proposalQuery.isPending.value"
|
||||
state="loading"
|
||||
title="Agent-Vorschlag wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="proposalQuery.isError.value"
|
||||
state="error"
|
||||
title="Agent-Vorschlag nicht verfügbar"
|
||||
:problem="proposalQuery.error.value"
|
||||
@action="proposalQuery.refetch()"
|
||||
/>
|
||||
|
||||
<template v-else-if="proposal">
|
||||
<section class="status-hero nexus-panel" :class="`status-hero--${statusMeta.tone}`">
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../api/openclawRuntime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { OpenClawAgent } from '../types/openclaw'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -254,8 +255,20 @@ function proposalStatus(proposal: AgentProposalDto) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="load-error nexus-state nexus-state--loading" role="status">Lade Gateway-Status...</div>
|
||||
<div v-else-if="error" class="load-error nexus-state nexus-state--error" role="alert">{{ error }}</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading"
|
||||
state="loading"
|
||||
title="Agenten werden geladen"
|
||||
compact
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
title="Agenten konnten nicht geladen werden"
|
||||
:problem="agentsQuery.error.value"
|
||||
compact
|
||||
@action="agentsQuery.refetch()"
|
||||
/>
|
||||
<div v-if="gatewayWarning" class="gateway-warning nexus-state nexus-status--warning" role="status">
|
||||
{{ gatewayWarning }}
|
||||
</div>
|
||||
@@ -368,10 +381,12 @@ function proposalStatus(proposal: AgentProposalDto) {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state nexus-state nexus-state--empty">
|
||||
<h3>Keine Agenten sichtbar</h3>
|
||||
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="!loading"
|
||||
state="empty"
|
||||
title="Keine Agenten sichtbar"
|
||||
message="Mission Control hat aktuell keine Agenten erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useOpenClawCronRuns,
|
||||
type CreateOpenClawCronJobRequest,
|
||||
} from '../api/openClawCron'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type {
|
||||
@@ -870,28 +871,27 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div v-if="cronJobsLoading && !collection" class="nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
||||
<p>Loading OpenClaw schedules…</p>
|
||||
</div>
|
||||
<div v-else-if="cronJobsError && !collection" class="nexus-state nexus-state--error" role="alert">
|
||||
<AlertCircle :size="18" aria-hidden="true" />
|
||||
<h2>Scheduler unavailable</h2>
|
||||
<p>{{ cronJobsError }}</p>
|
||||
<RouterLink class="nexus-button" to="/settings">OpenClaw diagnostics</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
<AsyncStatePanel
|
||||
v-if="cronJobsLoading && !collection"
|
||||
state="loading"
|
||||
title="OpenClaw-Zeitpläne werden geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="cronJobsError && !collection"
|
||||
state="error"
|
||||
title="Scheduler nicht verfügbar"
|
||||
:problem="jobsQuery.error.value"
|
||||
action-label="OpenClaw-Diagnose öffnen"
|
||||
@action="router.push('/settings')"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="collection && collection.state !== 'ready'"
|
||||
class="nexus-state"
|
||||
:class="{ 'nexus-state--error': ['disconnected', 'error', 'failed'].includes(collection.state) }"
|
||||
role="status"
|
||||
>
|
||||
<AlertCircle :size="18" aria-hidden="true" />
|
||||
<h2>Scheduler {{ collection.state }}</h2>
|
||||
<p>{{ collection.message || 'OpenClaw did not return its cron catalog.' }}</p>
|
||||
<p v-if="collection.recovery">{{ collection.recovery }}</p>
|
||||
<RouterLink class="nexus-button" to="/settings">Inspect connection</RouterLink>
|
||||
</div>
|
||||
:state="['disconnected', 'error', 'failed'].includes(collection.state) ? 'offline' : 'partial'"
|
||||
:title="`Scheduler: ${collection.state}`"
|
||||
:message="collection.recovery || collection.message || 'OpenClaw hat keinen vollständigen Cron-Katalog geliefert.'"
|
||||
action-label="Verbindung prüfen"
|
||||
@action="router.push('/settings')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<section class="calendar-layout">
|
||||
|
||||
@@ -27,6 +27,7 @@ import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
||||
import AsyncStatePanel from '../../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
/* ── Stores ──────────────────────────────────────── */
|
||||
const agentStore = useAgentStore()
|
||||
@@ -141,12 +142,6 @@ const taskItems = computed(() => activeCards.value.map(mapTask))
|
||||
const blockedTasks = computed(() =>
|
||||
activeCards.value.filter(task => task.state.toLowerCase() === 'blocked'),
|
||||
)
|
||||
const taskError = computed(() =>
|
||||
taskBoard.query.error.value instanceof Error
|
||||
? taskBoard.query.error.value.message
|
||||
: null,
|
||||
)
|
||||
|
||||
function handleBlockerClick() {
|
||||
const blockedTask = blockedTasks.value[0]
|
||||
if (!blockedTask) return
|
||||
@@ -181,7 +176,22 @@ function blockerCount() {
|
||||
@blocker-click="handleBlockerClick"
|
||||
/>
|
||||
|
||||
<AsyncStatePanel
|
||||
v-if="overviewQuery.isPending.value && !agentNodes.length"
|
||||
class="orchestration-state"
|
||||
state="loading"
|
||||
title="Live-Orchestrierung wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="overviewQuery.error.value && !agentNodes.length"
|
||||
class="orchestration-state"
|
||||
state="offline"
|
||||
title="Live-Orchestrierung nicht erreichbar"
|
||||
:problem="overviewQuery.error.value"
|
||||
@action="overviewQuery.refetch()"
|
||||
/>
|
||||
<FlowCanvas
|
||||
v-else
|
||||
:agents="agentNodes"
|
||||
:positions="agentPositions"
|
||||
:entering-ids="enteringIds"
|
||||
@@ -193,7 +203,8 @@ function blockerCount() {
|
||||
<TaskStrip
|
||||
:tasks="taskItems"
|
||||
:loading="taskBoard.query.isLoading.value"
|
||||
:error="taskError"
|
||||
:error="taskBoard.query.error.value"
|
||||
@retry="taskBoard.query.refetch()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,6 +253,11 @@ function blockerCount() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orchestration-state {
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.board-body {
|
||||
padding: 8px;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter, Loader2 } from '@lucide/vue'
|
||||
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useDocQuery, useDocsQuery } from '../api/knowledge'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
// State
|
||||
const docsQuery = useDocsQuery()
|
||||
@@ -22,10 +23,8 @@ const selectedDocInfo = computed(() =>
|
||||
docs.value.find(doc => doc.path === selectedDocPath.value) ?? null,
|
||||
)
|
||||
const contentLoading = computed(() => docQuery.isFetching.value)
|
||||
const error = computed(() => {
|
||||
const cause = docQuery.error.value ?? docsQuery.error.value
|
||||
return cause instanceof Error ? cause.message : ''
|
||||
})
|
||||
const listProblem = computed(() => docsQuery.error.value)
|
||||
const contentProblem = computed(() => docQuery.error.value)
|
||||
|
||||
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
|
||||
|
||||
@@ -50,6 +49,14 @@ function goBack() {
|
||||
selectedDocPath.value = ''
|
||||
}
|
||||
|
||||
function retryList() {
|
||||
void docsQuery.refetch()
|
||||
}
|
||||
|
||||
function retryContent() {
|
||||
void docQuery.refetch()
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
@@ -98,12 +105,30 @@ function formatSize(bytes: number | string): string {
|
||||
<div class="memory-layout">
|
||||
<!-- Left column: document list -->
|
||||
<aside class="memory-sidebar">
|
||||
<div v-if="loading" class="memory-status">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading documents...
|
||||
</div>
|
||||
<div v-else-if="error" class="memory-status error">{{ error }}</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !docs.length"
|
||||
state="loading"
|
||||
title="Dokumente werden geladen"
|
||||
compact
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="listProblem && !docs.length"
|
||||
state="error"
|
||||
title="Dokumente nicht verfügbar"
|
||||
:problem="listProblem"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<template v-else-if="filteredDocs.length">
|
||||
<AsyncStatePanel
|
||||
v-if="listProblem"
|
||||
state="stale"
|
||||
title="Dokumentliste möglicherweise veraltet"
|
||||
:problem="listProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<div class="memory-list-header">{{ filteredDocs.length }} documents</div>
|
||||
<button
|
||||
v-for="doc in filteredDocs"
|
||||
@@ -129,18 +154,39 @@ function formatSize(bytes: number | string): string {
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="memory-status">No documents match your filters</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine passenden Dokumente"
|
||||
message="Passe Suche oder Kategorie an."
|
||||
compact
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Right column: content -->
|
||||
<main class="memory-content">
|
||||
<template v-if="contentLoading">
|
||||
<div class="memory-status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading content...
|
||||
</div>
|
||||
</template>
|
||||
<AsyncStatePanel
|
||||
v-if="contentLoading && !selectedDoc"
|
||||
state="loading"
|
||||
title="Dokument wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="contentProblem && !selectedDoc"
|
||||
state="error"
|
||||
title="Dokument nicht verfügbar"
|
||||
:problem="contentProblem"
|
||||
@action="retryContent"
|
||||
/>
|
||||
<template v-else-if="selectedDoc">
|
||||
<AsyncStatePanel
|
||||
v-if="contentProblem"
|
||||
state="stale"
|
||||
title="Angezeigtes Dokument ist möglicherweise veraltet"
|
||||
:problem="contentProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryContent"
|
||||
/>
|
||||
<header class="memory-content-header">
|
||||
<button type="button" class="memory-back-btn" @click="goBack">
|
||||
<ArrowLeft :size="14" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { FileText, AlertTriangle, AlertCircle, Info, Activity, ArrowLeft, Clock, Loader2, ServerCog } from '@lucide/vue'
|
||||
import { AlertTriangle, AlertCircle, Info, ArrowLeft, Clock, ServerCog } from '@lucide/vue'
|
||||
import { useIncidentQuery, useIncidentsQuery } from '../api/incidents'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const overviewQuery = useOpenClawOverviewQuery()
|
||||
const incidentsQuery = useIncidentsQuery()
|
||||
@@ -21,10 +22,8 @@ const selectedIncidentSummary = computed(() =>
|
||||
incidents.value.find(incident => incident.name === selectedIncidentName.value) ?? null,
|
||||
)
|
||||
const contentLoading = computed(() => incidentQuery.isFetching.value)
|
||||
const error = computed(() => {
|
||||
const cause = incidentQuery.error.value ?? incidentsQuery.error.value
|
||||
return cause instanceof Error ? cause.message : ''
|
||||
})
|
||||
const listProblem = computed(() => incidentsQuery.error.value)
|
||||
const contentProblem = computed(() => incidentQuery.error.value)
|
||||
|
||||
// Sorted incidents (newest first by date)
|
||||
const sortedIncidents = computed(() => {
|
||||
@@ -68,6 +67,18 @@ function goBack() {
|
||||
selectedIncidentName.value = ''
|
||||
}
|
||||
|
||||
function retryList() {
|
||||
void incidentsQuery.refetch()
|
||||
}
|
||||
|
||||
function retryContent() {
|
||||
void incidentQuery.refetch()
|
||||
}
|
||||
|
||||
function retryRuntime() {
|
||||
void overviewQuery.refetch()
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
@@ -118,19 +129,44 @@ function formatSize(bytes: number | string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AsyncStatePanel
|
||||
v-else-if="overviewQuery.error.value"
|
||||
state="partial"
|
||||
title="Live-Incidents nicht vollständig"
|
||||
message="Post-Mortems bleiben verfügbar; aktuelle OpenClaw-Fehler konnten nicht synchronisiert werden."
|
||||
:problem="overviewQuery.error.value"
|
||||
action-label="Runtime aktualisieren"
|
||||
compact
|
||||
@action="retryRuntime"
|
||||
/>
|
||||
|
||||
<div class="incident-layout">
|
||||
<!-- Left column: incident list -->
|
||||
<aside class="incident-sidebar">
|
||||
<template v-if="loading">
|
||||
<div class="incident-status">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading incidents...
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="error">
|
||||
<div class="incident-status error">{{ error }}</div>
|
||||
</template>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !incidents.length"
|
||||
state="loading"
|
||||
title="Incidents werden geladen"
|
||||
compact
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="listProblem && !incidents.length"
|
||||
state="error"
|
||||
title="Incident-Berichte nicht verfügbar"
|
||||
:problem="listProblem"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<template v-else-if="sortedIncidents.length">
|
||||
<AsyncStatePanel
|
||||
v-if="listProblem"
|
||||
state="stale"
|
||||
title="Incident-Liste möglicherweise veraltet"
|
||||
:problem="listProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<div class="incident-list-header">{{ sortedIncidents.length }} reports</div>
|
||||
<button
|
||||
v-for="inc in sortedIncidents"
|
||||
@@ -154,18 +190,39 @@ function formatSize(bytes: number | string): string {
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="incident-status">No incidents recorded</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine Incidents erfasst"
|
||||
message="Es liegen weder auswählbare Post-Mortems noch bestätigte Incident-Berichte vor."
|
||||
compact
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Right column: detail view -->
|
||||
<main class="incident-content">
|
||||
<template v-if="contentLoading">
|
||||
<div class="incident-status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading incident...
|
||||
</div>
|
||||
</template>
|
||||
<AsyncStatePanel
|
||||
v-if="contentLoading && !selectedIncident"
|
||||
state="loading"
|
||||
title="Incident wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="contentProblem && !selectedIncident"
|
||||
state="error"
|
||||
title="Incident nicht verfügbar"
|
||||
:problem="contentProblem"
|
||||
@action="retryContent"
|
||||
/>
|
||||
<template v-else-if="selectedIncident">
|
||||
<AsyncStatePanel
|
||||
v-if="contentProblem"
|
||||
state="stale"
|
||||
title="Angezeigter Incident ist möglicherweise veraltet"
|
||||
:problem="contentProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryContent"
|
||||
/>
|
||||
<header class="incident-content-header">
|
||||
<button type="button" class="incident-back-btn" @click="goBack">
|
||||
<ArrowLeft :size="14" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { FileText, Search, ArrowLeft, Clock, Database, Loader2 } from '@lucide/vue'
|
||||
import { FileText, Search, ArrowLeft, Clock, Database } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import {
|
||||
useMemoryFileQuery,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useMemorySearchQuery,
|
||||
} from '../api/knowledge'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
// State
|
||||
const memoriesQuery = useMemoryFilesQuery()
|
||||
@@ -27,10 +28,9 @@ const selectedMemory = computed(() =>
|
||||
selectedMemoryName.value ? memoryQuery.data.value ?? null : null,
|
||||
)
|
||||
const contentLoading = computed(() => memoryQuery.isFetching.value)
|
||||
const error = computed(() => {
|
||||
const cause = memoryQuery.error.value ?? memoriesQuery.error.value
|
||||
return cause instanceof Error ? cause.message : ''
|
||||
})
|
||||
const listProblem = computed(() => memoriesQuery.error.value)
|
||||
const contentProblem = computed(() => memoryQuery.error.value)
|
||||
const searchProblem = computed(() => searchResultsQuery.error.value)
|
||||
|
||||
// Sorted memories (newest first)
|
||||
const sortedMemories = computed(() => {
|
||||
@@ -63,6 +63,18 @@ function goBack() {
|
||||
selectedMemoryName.value = ''
|
||||
}
|
||||
|
||||
function retryList() {
|
||||
void memoriesQuery.refetch()
|
||||
}
|
||||
|
||||
function retrySearch() {
|
||||
void searchResultsQuery.refetch()
|
||||
}
|
||||
|
||||
function retryContent() {
|
||||
void memoryQuery.refetch()
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
@@ -109,11 +121,30 @@ onUnmounted(() => {
|
||||
<aside class="memory-sidebar">
|
||||
<!-- Search results -->
|
||||
<template v-if="searchQuery.trim().length >= 2">
|
||||
<div v-if="searchLoading" class="memory-status">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Searching...
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="searchLoading && !searchResults.length"
|
||||
state="loading"
|
||||
title="Memory wird durchsucht"
|
||||
compact
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="searchProblem && !searchResults.length"
|
||||
state="error"
|
||||
title="Suche fehlgeschlagen"
|
||||
:problem="searchProblem"
|
||||
compact
|
||||
@action="retrySearch"
|
||||
/>
|
||||
<template v-else-if="searchResults.length">
|
||||
<AsyncStatePanel
|
||||
v-if="searchProblem"
|
||||
state="stale"
|
||||
title="Suchergebnisse möglicherweise veraltet"
|
||||
:problem="searchProblem"
|
||||
action-label="Erneut suchen"
|
||||
compact
|
||||
@action="retrySearch"
|
||||
/>
|
||||
<div class="memory-list-header">Search results ({{ searchResults.length }})</div>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
@@ -130,17 +161,41 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="memory-status">No results found</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine Treffer"
|
||||
message="Passe den Suchbegriff an oder öffne die vollständige Dateiliste."
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- File list (default) -->
|
||||
<template v-else>
|
||||
<div v-if="loading" class="memory-status">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading memory files...
|
||||
</div>
|
||||
<div v-else-if="error" class="memory-status error">{{ error }}</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !memories.length"
|
||||
state="loading"
|
||||
title="Memory-Dateien werden geladen"
|
||||
compact
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="listProblem && !memories.length"
|
||||
state="error"
|
||||
title="Memory-Dateien nicht verfügbar"
|
||||
:problem="listProblem"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<template v-else-if="sortedMemories.length">
|
||||
<AsyncStatePanel
|
||||
v-if="listProblem"
|
||||
state="stale"
|
||||
title="Dateiliste möglicherweise veraltet"
|
||||
:problem="listProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryList"
|
||||
/>
|
||||
<div class="memory-list-header">{{ sortedMemories.length }} files</div>
|
||||
<button
|
||||
v-for="mem in sortedMemories"
|
||||
@@ -161,19 +216,40 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="memory-status">No memory files available</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Keine Memory-Dateien"
|
||||
message="OpenClaw meldet für die verbundenen Agent-Workspaces noch keine Memory-Dateien."
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- Right column: content -->
|
||||
<main class="memory-content">
|
||||
<template v-if="contentLoading">
|
||||
<div class="memory-status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading content...
|
||||
</div>
|
||||
</template>
|
||||
<AsyncStatePanel
|
||||
v-if="contentLoading && !selectedMemory"
|
||||
state="loading"
|
||||
title="Memory-Inhalt wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="contentProblem && !selectedMemory"
|
||||
state="error"
|
||||
title="Memory-Inhalt nicht verfügbar"
|
||||
:problem="contentProblem"
|
||||
@action="retryContent"
|
||||
/>
|
||||
<template v-else-if="selectedMemory">
|
||||
<AsyncStatePanel
|
||||
v-if="contentProblem"
|
||||
state="stale"
|
||||
title="Angezeigter Inhalt ist möglicherweise veraltet"
|
||||
:problem="contentProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="retryContent"
|
||||
/>
|
||||
<header class="memory-content-header">
|
||||
<button type="button" class="memory-back-btn" @click="goBack">
|
||||
<ArrowLeft :size="14" />
|
||||
|
||||
@@ -14,13 +14,15 @@ import {
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import {
|
||||
refreshOpenClawModelAuthStatus,
|
||||
useOpenClawModelAuthQuery,
|
||||
type OpenClawModelAuthProviderDto,
|
||||
} from '../api/openClawModels'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const modelAuthQuery = useOpenClawModelAuthQuery()
|
||||
const query = ref('')
|
||||
const statusFilter = ref('all')
|
||||
@@ -250,54 +252,38 @@ onUnmounted(() => {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div
|
||||
<AsyncStatePanel
|
||||
v-if="modelAuthQuery.isPending.value && !collection"
|
||||
class="nexus-state nexus-state--loading"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
||||
<p>Loading sanitized provider health from OpenClaw…</p>
|
||||
</div>
|
||||
<div
|
||||
state="loading"
|
||||
title="Provider-Status wird geladen"
|
||||
message="Nexus liest den bereinigten Authentifizierungsstatus aus OpenClaw."
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="modelAuthError && !collection"
|
||||
class="nexus-state nexus-state--error"
|
||||
role="alert"
|
||||
>
|
||||
<CircleAlert :size="18" aria-hidden="true" />
|
||||
<h2>Provider health unavailable</h2>
|
||||
<p>{{ modelAuthError }}</p>
|
||||
<div class="state-actions">
|
||||
<button type="button" class="nexus-button nexus-button--primary" @click="refresh">Try again</button>
|
||||
<RouterLink class="nexus-button" to="/settings">OpenClaw diagnostics</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
state="error"
|
||||
title="Provider-Status nicht verfügbar"
|
||||
:problem="modelAuthQuery.error.value"
|
||||
@action="refresh"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="collection && collection.state !== 'ready'"
|
||||
class="nexus-state"
|
||||
:class="{
|
||||
'nexus-state--error':
|
||||
collection.state === 'error'
|
||||
|| collection.state === 'disconnected'
|
||||
|| collection.state === 'forbidden',
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
<ShieldAlert :size="18" aria-hidden="true" />
|
||||
<h2>
|
||||
{{ collection.state === 'unsupported'
|
||||
? 'Provider health is not supported'
|
||||
: 'Provider health is not ready' }}
|
||||
</h2>
|
||||
<p>{{ collection.message || 'OpenClaw did not return provider authentication health.' }}</p>
|
||||
<p v-if="collection.recovery">{{ collection.recovery }}</p>
|
||||
<RouterLink class="nexus-button" to="/settings">Inspect connection</RouterLink>
|
||||
</div>
|
||||
:state="['error', 'disconnected', 'forbidden'].includes(collection.state) ? 'offline' : 'partial'"
|
||||
:title="collection.state === 'unsupported' ? 'Provider-Status nicht unterstützt' : `Provider-Status: ${collection.state}`"
|
||||
:message="collection.recovery || collection.message || 'OpenClaw hat keinen verwendbaren Provider-Snapshot geliefert.'"
|
||||
action-label="OpenClaw-Setup öffnen"
|
||||
@action="router.push('/settings')"
|
||||
/>
|
||||
|
||||
<template v-else-if="collection">
|
||||
<div v-if="modelAuthError" class="refresh-warning nexus-state nexus-state--error" role="alert">
|
||||
<CircleAlert :size="16" aria-hidden="true" />
|
||||
<p>Refresh failed. The last successful snapshot remains visible. {{ modelAuthError }}</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="modelAuthError"
|
||||
state="stale"
|
||||
title="Letzter bestätigter Provider-Stand"
|
||||
:problem="modelAuthQuery.error.value"
|
||||
message="Der Refresh ist fehlgeschlagen; der letzte bestätigte Snapshot bleibt sichtbar."
|
||||
compact
|
||||
@action="refresh"
|
||||
/>
|
||||
|
||||
<section class="model-toolbar nexus-panel" aria-label="Provider health filters">
|
||||
<label class="model-search">
|
||||
@@ -359,14 +345,12 @@ onUnmounted(() => {
|
||||
<span v-if="provider.usage?.plan" class="model-plan">{{ provider.usage.plan }}</span>
|
||||
</button>
|
||||
</section>
|
||||
<div v-else class="nexus-state nexus-state--empty">
|
||||
<ShieldCheck :size="18" aria-hidden="true" />
|
||||
<h2>{{ providers.length ? 'No matching providers' : 'No provider health reported' }}</h2>
|
||||
<p v-if="providers.length">Adjust the provider search or status filter.</p>
|
||||
<p v-else>
|
||||
OpenClaw returned no configured authentication profiles or API-key sources.
|
||||
</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
:title="providers.length ? 'Keine passenden Provider' : 'Kein Provider-Status gemeldet'"
|
||||
:message="providers.length ? 'Passe Suche oder Statusfilter an.' : 'OpenClaw hat keine konfigurierten Auth-Profile gemeldet.'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Teleport to="body">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
} from '@lucide/vue'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -142,18 +143,24 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="notificationQuery.isLoading.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="24" class="spin" />
|
||||
<p>Benachrichtigungen werden geladen…</p>
|
||||
</div>
|
||||
<div v-else-if="notificationQuery.error.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--error" role="alert">
|
||||
<BellOff :size="28" />
|
||||
<p>{{ notificationQuery.error.value.message }}</p>
|
||||
</div>
|
||||
<div v-else-if="sortedNotifications.length === 0" class="empty-state nexus-state nexus-state--empty">
|
||||
<BellOff :size="48" />
|
||||
<p>Keine Benachrichtigungen</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="notificationQuery.isLoading.value && !sortedNotifications.length"
|
||||
state="loading"
|
||||
title="Benachrichtigungen werden geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="notificationQuery.error.value && !sortedNotifications.length"
|
||||
state="error"
|
||||
title="Benachrichtigungen nicht verfügbar"
|
||||
:problem="notificationQuery.error.value"
|
||||
@action="notificationQuery.refetch()"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="sortedNotifications.length === 0"
|
||||
state="empty"
|
||||
title="Keine Benachrichtigungen"
|
||||
message="Neue Aufgaben, Freigaben und Runtime-Ereignisse erscheinen hier."
|
||||
/>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useProject, useProjectTasks } from '../api/projects'
|
||||
import { useOpenClawRuns } from '../api/openClawRuns'
|
||||
import type { EntityRefDto } from '../api/contracts'
|
||||
import EntityLink from '../components/mission-control/EntityLink.vue'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -115,8 +116,19 @@ function getTaskStateIcon(state: string) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading-state">Loading project...</div>
|
||||
<div v-else-if="loadError" class="error-state">{{ loadError }}</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !project"
|
||||
state="loading"
|
||||
title="Projekt wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="loadError && !project"
|
||||
state="error"
|
||||
title="Projekt konnte nicht geladen werden"
|
||||
:message="loadError"
|
||||
action-label="Erneut versuchen"
|
||||
@action="projectState.query.refetch()"
|
||||
/>
|
||||
<template v-else-if="project">
|
||||
<div class="project-detail-card">
|
||||
<div class="project-detail-top">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Boxes, CalendarClock, CircleAlert, Loader2, Plus, RefreshCw } from '@lu
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useProjects } from '../api/projects'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const projects = useProjects()
|
||||
const name = ref('')
|
||||
@@ -94,25 +95,24 @@ async function submit() {
|
||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="projects.query.isLoading.value" class="nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="22" class="spin" aria-hidden="true" />
|
||||
<p>Projekte werden geladen…</p>
|
||||
</section>
|
||||
<section v-else-if="projects.query.isError.value" class="nexus-state nexus-state--error" role="alert">
|
||||
<CircleAlert :size="22" aria-hidden="true" />
|
||||
<div>
|
||||
<h2>Projektliste nicht verfügbar</h2>
|
||||
<p>{{ projects.query.error.value instanceof Error ? projects.query.error.value.message : 'Unbekannter Fehler' }}</p>
|
||||
</div>
|
||||
<button type="button" class="nexus-button" @click="projects.query.refetch()">Erneut versuchen</button>
|
||||
</section>
|
||||
<section v-else-if="!items.length" class="nexus-state nexus-state--empty">
|
||||
<Boxes :size="28" aria-hidden="true" />
|
||||
<div>
|
||||
<h2>Noch keine Projekte</h2>
|
||||
<p>Lege den ersten Mission-Scope über das Formular an.</p>
|
||||
</div>
|
||||
</section>
|
||||
<AsyncStatePanel
|
||||
v-if="projects.query.isLoading.value"
|
||||
state="loading"
|
||||
title="Projekte werden geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="projects.query.isError.value"
|
||||
state="error"
|
||||
title="Projektliste nicht verfügbar"
|
||||
:problem="projects.query.error.value"
|
||||
@action="projects.query.refetch()"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="!items.length"
|
||||
state="empty"
|
||||
title="Noch keine Projekte"
|
||||
message="Lege den ersten Mission-Scope über das Formular an."
|
||||
/>
|
||||
<section v-else class="project-grid" aria-label="Projekte">
|
||||
<RouterLink
|
||||
v-for="project in items"
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useMissionControlUiStore } from '../stores/missionControlUi'
|
||||
import { useOpenClawStore } from '../stores/openclaw'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
import type {
|
||||
OpenClawActivity,
|
||||
OpenClawApproval,
|
||||
@@ -423,28 +424,29 @@ onMounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="overviewQuery.isPending.value && !overview" class="nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
<div><h2>OpenClaw control plane wird geladen</h2><p>Gateway handshake and capability negotiation are in progress.</p></div>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="overviewQuery.isPending.value && !overview"
|
||||
state="loading"
|
||||
title="OpenClaw Control Plane wird geladen"
|
||||
message="Gateway-Handshake und Capability-Aushandlung laufen."
|
||||
/>
|
||||
|
||||
<div v-else-if="overviewError && !overview" class="nexus-state nexus-state--error" role="alert">
|
||||
<AlertTriangle :size="20" />
|
||||
<div><h2>Run Control ist nicht erreichbar</h2><p>{{ overviewError }}</p></div>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="overviewError && !overview"
|
||||
state="error"
|
||||
title="Run Control ist nicht erreichbar"
|
||||
:problem="overviewQuery.error.value"
|
||||
@action="overviewQuery.refetch()"
|
||||
/>
|
||||
|
||||
<section v-else-if="connection && !connection.connected" class="disconnected-state nexus-state nexus-state--error">
|
||||
<Network :size="24" />
|
||||
<div>
|
||||
<h2>OpenClaw Gateway ist {{ connection.state }}</h2>
|
||||
<p>{{ connection.recovery || connection.message || 'Gateway configuration and credentials must be checked.' }}</p>
|
||||
<code v-if="connection.pairingRequired">
|
||||
Pairing request: {{ connection.pairingRequestId || 'not reported' }}
|
||||
</code>
|
||||
<code>{{ connection.endpoint }}</code>
|
||||
</div>
|
||||
<RouterLink class="nexus-button" to="/settings">Integration prüfen</RouterLink>
|
||||
</section>
|
||||
<AsyncStatePanel
|
||||
v-else-if="connection && !connection.connected"
|
||||
state="offline"
|
||||
:title="`OpenClaw Gateway ist ${connection.state}`"
|
||||
:message="connection.recovery || connection.message || 'Gateway-Konfiguration und Zugang müssen geprüft werden.'"
|
||||
action-label="Integration prüfen"
|
||||
@action="router.push('/settings')"
|
||||
/>
|
||||
|
||||
<template v-else-if="overview">
|
||||
<section class="run-strip" aria-label="OpenClaw control-plane status">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../api/openClawRuns'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOpenClawStore } from '../stores/openclaw'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
type RunAction = 'stop' | 'retry' | 'resume'
|
||||
|
||||
@@ -202,16 +203,21 @@ async function executeAction() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="detailLoading && !run" class="nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
<div><h2>Loading durable run</h2><p>Nexus is reconciling local transitions with OpenClaw history.</p></div>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="detailLoading && !run"
|
||||
state="loading"
|
||||
title="Durable Run wird geladen"
|
||||
message="Nexus gleicht lokale Transitionen mit der OpenClaw-Historie ab."
|
||||
/>
|
||||
|
||||
<div v-else-if="detailError && !run" class="nexus-state nexus-state--error" role="alert">
|
||||
<AlertTriangle :size="20" />
|
||||
<div><h2>Run could not be loaded</h2><p>{{ detailError }}</p></div>
|
||||
<RouterLink class="nexus-button" to="/runs">Return to Run Control</RouterLink>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="detailError && !run"
|
||||
state="error"
|
||||
title="Run konnte nicht geladen werden"
|
||||
:message="detailError"
|
||||
action-label="Zur Run Control"
|
||||
@action="router.push('/runs')"
|
||||
/>
|
||||
|
||||
<template v-else-if="run">
|
||||
<section v-if="run.sequenceGapDetected" class="gap-warning" role="alert">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useOpenClawOverviewQuery,
|
||||
} from '../api/openclawRuntime'
|
||||
import { useSecurityStatusQuery } from '../api/security'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const statusQuery = useSecurityStatusQuery()
|
||||
const status = computed(() => statusQuery.data.value ?? null)
|
||||
@@ -165,15 +166,18 @@ async function refresh() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading && !status" class="nexus-state nexus-state--loading" role="status">
|
||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
||||
<p>Loading Nexus security configuration…</p>
|
||||
</div>
|
||||
<div v-else-if="error && !status" class="nexus-state nexus-state--error" role="alert">
|
||||
<CircleAlert :size="18" aria-hidden="true" />
|
||||
<h2>Nexus security status unavailable</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !status"
|
||||
state="loading"
|
||||
title="Security-Konfiguration wird geladen"
|
||||
/>
|
||||
<AsyncStatePanel
|
||||
v-else-if="error && !status"
|
||||
state="error"
|
||||
title="Nexus Security-Status nicht verfügbar"
|
||||
:problem="statusQuery.error.value"
|
||||
@action="refresh"
|
||||
/>
|
||||
|
||||
<section v-else-if="status" class="security-grid" aria-label="Nexus security controls">
|
||||
<article class="security-card nexus-card">
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Save, Lock, User, Shield, Plus, Mail, Trash2, Users,
|
||||
Save, Lock, User, Shield, Plus, Mail, Trash2,
|
||||
Eye, EyeOff, CheckCircle, X,
|
||||
} from '@lucide/vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { throwApiProblem } from '../api/contracts'
|
||||
import OpenClawConfigEditor from '../components/openclaw/OpenClawConfigEditor.vue'
|
||||
import OpenClawSetupCenter from '../components/openclaw/OpenClawSetupCenter.vue'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -119,7 +121,7 @@ interface AdminUser {
|
||||
|
||||
const users = ref<AdminUser[]>([])
|
||||
const usersLoading = ref(false)
|
||||
const usersError = ref('')
|
||||
const usersProblem = ref<unknown>(null)
|
||||
const showCreateUser = ref(false)
|
||||
const createEmail = ref('')
|
||||
const createPassword = ref('')
|
||||
@@ -134,13 +136,13 @@ const canManageUsers = auth.user?.role === 'owner' || auth.user?.role === 'admin
|
||||
async function loadUsers() {
|
||||
if (!canManageUsers) return
|
||||
usersLoading.value = true
|
||||
usersError.value = ''
|
||||
usersProblem.value = null
|
||||
try {
|
||||
const res = await apiFetch('/api/v1/admin/users')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
if (!res.ok) await throwApiProblem(res, 'Benutzer konnten nicht geladen werden')
|
||||
users.value = await res.json()
|
||||
} catch (e) {
|
||||
usersError.value = e instanceof Error ? e.message : 'Benutzer konnten nicht geladen werden'
|
||||
usersProblem.value = e
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
@@ -339,38 +341,60 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="usersError" class="msg error">{{ usersError }}</p>
|
||||
<AsyncStatePanel
|
||||
v-if="usersLoading && !users.length"
|
||||
state="loading"
|
||||
title="Benutzer werden geladen"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div v-if="usersLoading" class="loading-pulse">
|
||||
<div class="pulse-bar"></div>
|
||||
<div class="pulse-bar"></div>
|
||||
<div class="pulse-bar"></div>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="usersProblem && !users.length"
|
||||
state="error"
|
||||
title="Benutzer nicht verfügbar"
|
||||
:problem="usersProblem"
|
||||
compact
|
||||
@action="loadUsers"
|
||||
/>
|
||||
|
||||
<div v-else-if="users.length === 0" class="empty-state">
|
||||
<Users :size="32" />
|
||||
<p>Noch keine Benutzer. Lege den ersten an.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="user-list">
|
||||
<div v-for="user in users" :key="user.id" class="user-row">
|
||||
<div class="user-avatar">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ user.displayName }}</div>
|
||||
<div class="user-email">
|
||||
<Mail :size="11" /> {{ user.email }}
|
||||
<template v-else-if="users.length">
|
||||
<AsyncStatePanel
|
||||
v-if="usersProblem"
|
||||
state="stale"
|
||||
title="Benutzerliste möglicherweise veraltet"
|
||||
:problem="usersProblem"
|
||||
action-label="Aktualisieren"
|
||||
compact
|
||||
@action="loadUsers"
|
||||
/>
|
||||
<div class="user-list">
|
||||
<div v-for="user in users" :key="user.id" class="user-row">
|
||||
<div class="user-avatar">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ user.displayName }}</div>
|
||||
<div class="user-email">
|
||||
<Mail :size="11" /> {{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<span class="role-tag" :class="user.role">{{ user.role }}</span>
|
||||
<span class="user-date">
|
||||
Seit {{ formatDate(user.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<span class="role-tag" :class="user.role">{{ user.role }}</span>
|
||||
<span class="user-date">
|
||||
Seit {{ formatDate(user.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AsyncStatePanel
|
||||
v-else
|
||||
state="empty"
|
||||
title="Noch keine Benutzer"
|
||||
message="Lege den ersten zusätzlichen Nexus-Benutzer an."
|
||||
compact
|
||||
/>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<Teleport to="body">
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||
import { useTaskBoard } from '../api/taskBoard'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
import { useTaskActivity, useTaskChildren } from '../api/tasks'
|
||||
import { subscribeDomainEventState } from '../services/domainEvents'
|
||||
import type { SseConnectionState } from '../services/sseHub'
|
||||
@@ -603,18 +604,32 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="taskBoard.query.isLoading.value" class="board-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Lade Aufgaben…</span>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="taskBoard.query.isLoading.value && !taskBoard.query.data.value"
|
||||
state="loading"
|
||||
title="Aufgaben werden geladen"
|
||||
/>
|
||||
|
||||
<div v-else-if="taskBoard.query.isError.value" class="board-loading" role="alert">
|
||||
<AlertTriangle :size="18" />
|
||||
<span>Das Task Board konnte nicht geladen werden.</span>
|
||||
<button type="button" class="btn-ghost" @click="taskBoard.query.refetch()">Erneut versuchen</button>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="taskBoard.query.isError.value && !taskBoard.query.data.value"
|
||||
state="error"
|
||||
title="Task Board konnte nicht geladen werden"
|
||||
:problem="taskBoard.query.error.value"
|
||||
@action="taskBoard.query.refetch()"
|
||||
/>
|
||||
|
||||
<div v-else class="board-columns">
|
||||
<AsyncStatePanel
|
||||
v-if="taskBoard.query.isError.value && taskBoard.query.data.value"
|
||||
state="stale"
|
||||
title="Letzter bestätigter Board-Stand"
|
||||
:problem="taskBoard.query.error.value"
|
||||
message="Die sichtbaren Karten bleiben erhalten, während Nexus die Verbindung erneut prüft."
|
||||
action-label="Erneut synchronisieren"
|
||||
compact
|
||||
@action="taskBoard.query.refetch()"
|
||||
/>
|
||||
|
||||
<div v-if="taskBoard.query.data.value" class="board-columns">
|
||||
<div
|
||||
class="col"
|
||||
:class="{ 'drag-over': dragOverColumn === 'offen' }"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { reconcileTaskBoardCard } from '../api/taskBoard'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { reportOperationEnvelope } from '../services/operationResults'
|
||||
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||
import {
|
||||
buildTaskAgentOptions,
|
||||
taskAgentLabel,
|
||||
@@ -411,18 +412,20 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
Zurück zum Board
|
||||
</button>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Lade Aufgabe…</span>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-if="loading && !task"
|
||||
state="loading"
|
||||
title="Aufgabe wird geladen"
|
||||
/>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<AlertCircle :size="32" />
|
||||
<p>{{ error }}</p>
|
||||
<button type="button" class="btn-primary" @click="loadTask">Erneut versuchen</button>
|
||||
</div>
|
||||
<AsyncStatePanel
|
||||
v-else-if="error && !task"
|
||||
state="error"
|
||||
title="Aufgabe konnte nicht geladen werden"
|
||||
:problem="taskDetail.taskQuery.error.value"
|
||||
:message="error"
|
||||
@action="loadTask"
|
||||
/>
|
||||
|
||||
<!-- Task Detail -->
|
||||
<template v-else-if="task">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AppProblem, ApiProblem, asProblemDetails, toAppProblem } from '../src/api/contracts'
|
||||
|
||||
describe('AppProblem', () => {
|
||||
it('preserves structured conflict recovery metadata', () => {
|
||||
const problem = new AppProblem(409, {
|
||||
code: 'conflict',
|
||||
detail: 'The resource changed.',
|
||||
traceId: 'trace-1',
|
||||
operationId: 'operation-1',
|
||||
currentRevision: 7,
|
||||
}, 'fallback')
|
||||
|
||||
expect(problem.kind).toBe('conflict')
|
||||
expect(problem.recoveryAction).toBe('reload-conflict')
|
||||
expect(problem.traceId).toBe('trace-1')
|
||||
expect(problem.operationId).toBe('operation-1')
|
||||
expect(problem.currentRevision).toBe(7)
|
||||
})
|
||||
|
||||
it('maps network failures to a recoverable offline state', () => {
|
||||
const problem = toAppProblem(new TypeError('Failed to fetch'))
|
||||
|
||||
expect(problem.kind).toBe('offline')
|
||||
expect(problem.recoveryAction).toBe('inspect-connection')
|
||||
})
|
||||
|
||||
it('does not turn missing conflict metadata into revision zero', () => {
|
||||
const problem = new AppProblem(409, { code: 'conflict' }, 'fallback')
|
||||
|
||||
expect(problem.currentRevision).toBeNull()
|
||||
expect(problem.retryAfterSeconds).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the legacy ApiProblem compatible with the common contract', () => {
|
||||
const problem = new ApiProblem(401, { detail: 'Session expired.' }, 'fallback')
|
||||
|
||||
expect(problem).toBeInstanceOf(AppProblem)
|
||||
expect(problem.kind).toBe('authentication')
|
||||
expect(problem.recoveryAction).toBe('reauthenticate')
|
||||
})
|
||||
|
||||
it('normalizes a legacy message payload at the frontend boundary', () => {
|
||||
const problem = asProblemDetails({ message: 'Gateway unavailable.' }, 503)
|
||||
|
||||
expect(problem?.detail).toBe('Gateway unavailable.')
|
||||
expect(problem?.status).toBe(503)
|
||||
})
|
||||
|
||||
it('normalizes a legacy error payload without replacing structured details', () => {
|
||||
expect(asProblemDetails({ error: 'Legacy error.' }, 400)?.detail).toBe('Legacy error.')
|
||||
expect(asProblemDetails({ detail: 'Canonical.', error: 'Legacy.' }, 409)?.detail).toBe('Canonical.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 'authentication', 'reauthenticate'],
|
||||
[403, 'permission', 'none'],
|
||||
[409, 'conflict', 'reload-conflict'],
|
||||
[429, 'rate-limit', 'retry'],
|
||||
[503, 'offline', 'inspect-connection'],
|
||||
[504, 'timeout', 'retry'],
|
||||
] as const)(
|
||||
'maps HTTP %s to the %s recovery presentation',
|
||||
(status, kind, recoveryAction) => {
|
||||
const problem = new AppProblem(status, null, `HTTP ${status}`)
|
||||
|
||||
expect(problem.kind).toBe(kind)
|
||||
expect(problem.recoveryAction).toBe(recoveryAction)
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user