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

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+5
View File
@@ -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* ./
+27
View File
@@ -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 }) => {
+29
View File
@@ -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 -1
View File
@@ -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",
+3
View File
@@ -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 -6
View File
@@ -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 {
+145 -9
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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>
+13 -1
View File
@@ -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
}
+8 -4
View File
@@ -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,
+9 -2
View File
@@ -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,
)
+22 -23
View File
@@ -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">
+14 -9
View File
@@ -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
+155 -75
View File
@@ -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"
+13 -9
View File
@@ -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}`">
+21 -6
View File
@@ -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>
+21 -21
View File
@@ -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">
+23 -7
View File
@@ -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;
+63 -17
View File
@@ -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" />
+78 -21
View File
@@ -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" />
+98 -22
View File
@@ -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" />
+36 -52
View File
@@ -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">
+19 -12
View File
@@ -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
+14 -2
View File
@@ -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">
+19 -19
View File
@@ -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"
+22 -20
View File
@@ -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">
+15 -9
View File
@@ -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">
+13 -9
View File
@@ -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">
+56 -32
View File
@@ -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">
+25 -10
View File
@@ -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' }"
+14 -11
View File
@@ -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">
+71
View File
@@ -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)
},
)
})