Refine panel assistants and observability routing

This commit is contained in:
Haitao Pan 2026-03-15 12:15:03 +08:00
parent 2cb3f4ee88
commit bfd9a9ca63
6 changed files with 482 additions and 30 deletions

View File

@ -9,7 +9,7 @@ import {
Rocket,
Database,
Key,
History,
Activity,
Settings,
Plus,
} from 'lucide-react'
@ -20,7 +20,7 @@ const navItems = [
{ href: '/deployments', label: 'Deployments', icon: Rocket },
{ href: '/resources', label: 'Resources', icon: Database },
{ href: '/api-keys', label: 'API Keys', icon: Key },
{ href: '/logs', label: 'Logs', icon: History },
{ href: '/panel/observability', label: 'Observability', icon: Activity },
{ href: '/settings', label: 'Settings', icon: Settings },
]

View File

@ -50,6 +50,9 @@ type OpenClawAssistantPaneProps = {
initialQuestion?: string;
initialQuestionKey?: number;
variant?: "page" | "sidebar";
showConversation?: boolean;
emptyConversationHint?: string;
onStateChange?: (state: OpenClawAssistantViewState) => void;
};
type ComposerAttachment = GatewayChatAttachmentPayload & {
@ -60,6 +63,23 @@ type ComposerAttachment = GatewayChatAttachmentPayload & {
type ConnectionState = "idle" | "connecting" | "ready" | "error";
export type OpenClawAssistantViewState = {
connectionState: ConnectionState;
healthBadge: string;
errorMessage: string;
hasGateway: boolean;
selectedSessionLabel: string;
streamingText: string;
streamingHtml: string;
messages: Array<{
id: string;
role: string;
text: string;
html: string;
timestampMs?: number;
}>;
};
function pickCopy(isChinese: boolean, zh: string, en: string): string {
return isChinese ? zh : en;
}
@ -195,6 +215,9 @@ export function OpenClawAssistantPane({
initialQuestion,
initialQuestionKey,
variant = "page",
showConversation = true,
emptyConversationHint,
onStateChange,
}: OpenClawAssistantPaneProps) {
const router = useRouter();
const { language } = useLanguage();
@ -432,6 +455,34 @@ export function OpenClawAssistantPane({
[messages, minimalPage],
);
useEffect(() => {
onStateChange?.({
connectionState,
healthBadge,
errorMessage,
hasGateway: Boolean(openclawUrl.trim()),
selectedSessionLabel:
activeSession?.derivedTitle ||
activeSession?.displayName ||
selectedSessionKey ||
copy.mainSession,
streamingText,
streamingHtml: streamingText ? renderMarkdown(streamingText) : "",
messages: renderedMessages,
});
}, [
activeSession,
connectionState,
copy.mainSession,
errorMessage,
healthBadge,
onStateChange,
openclawUrl,
renderedMessages,
selectedSessionKey,
streamingText,
]);
const connectGateway = useCallback(
async (nextSessionKey?: string, nextAgentId?: string): Promise<void> => {
if (!openclawUrl.trim()) {
@ -904,7 +955,26 @@ export function OpenClawAssistantPane({
) : null}
<div className="flex-1 overflow-y-auto px-3 py-3">
{!openclawUrl.trim() ? (
{!showConversation ? (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-[var(--radius-xl)] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-5 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
<Sparkles className="h-6 w-6" />
</div>
<div className="space-y-2">
<h3 className="text-base font-semibold text-[var(--color-heading)]">
{copy.assistantTitle}
</h3>
<p className="text-sm text-[var(--color-text-subtle)]">
{emptyConversationHint ??
pickCopy(
isChinese,
"在右侧发起任务,中间区域会同步展示助手结果。",
"Start tasks from the right panel. Results will be mirrored in the center workspace.",
)}
</p>
</div>
</div>
) : !openclawUrl.trim() ? (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-[var(--radius-xl)] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-5 text-center">
<Sparkles className="h-7 w-7 text-[var(--color-primary)]" />
<div className="space-y-2">

View File

@ -998,7 +998,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
deployments: 'Deployments',
resources: 'Resources',
apiKeys: 'API Keys',
logs: 'Logs',
logs: 'Observability',
settings: 'Settings',
},
overview: {
@ -1168,10 +1168,28 @@ export const translations: Record<'en' | 'zh', Translation> = {
{
title: 'Full-link SaaS Hosting',
description: 'Provide one-stop hosting services from development and deployment to maintenance, simplifying architectural complexity and helping applications quickly achieve SaaS transformation.',
guide: {
title: 'Deployments Console Guide',
dismiss: 'Exit Guide',
steps: [
{ text: 'Use the deployments console as the operational entry for release progress, blockers, and execution history.' },
{ text: 'Open the Deployments workspace and let X Assistant generate the rollout checklist or summarize current status.', link: { url: '/panel/deployments', label: 'Open Deployments Console' } },
{ text: 'Once connected, ask for deployment diagnosis, release steps, or rollback suggestions, and the result stream will appear in the center workspace.' },
],
},
},
{
title: 'AI-Driven Observability',
description: 'Utilize AI to intelligently analyze full-link logs and performance metrics, identifying potential anomalies in real-time and providing predictive insights to ensure smooth system operation.',
guide: {
title: 'Observability Console Guide',
dismiss: 'Exit Guide',
steps: [
{ text: 'Use the observability console as the AI entry for logs, metrics, and anomaly investigation.' },
{ text: 'Open the Observability workspace and start from log analysis, timeline summaries, or incident review.', link: { url: '/panel/observability', label: 'Open Observability Console' } },
{ text: 'X Assistant stays open on the right while the center pane continuously mirrors analysis results, remediation ideas, and summaries.' },
],
},
},
],
nextSteps: {
@ -1778,7 +1796,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
deployments: '部署管理',
resources: '资源列表',
apiKeys: '接口密钥',
logs: '运行日志',
logs: '可观测性',
settings: '系统设置',
},
overview: {
@ -1944,10 +1962,28 @@ export const translations: Record<'en' | 'zh', Translation> = {
{
title: '全链路 SaaS 托管',
description: '提供从开发、部署到维护的一站式托管服务,简化架构复杂度,助力应用快速实现 SaaS 化转型。',
guide: {
title: '部署控制台向导',
dismiss: '退出向导',
steps: [
{ text: '把部署控制台作为发布进度、阻塞项和执行记录的统一操作入口。' },
{ text: '打开 Deployments 工作区,让 X 助手为你生成部署检查清单,或者总结当前发布状态。', link: { url: '/panel/deployments', label: '打开 Deployments 控制台' } },
{ text: '接入完成后,可以继续让助手分析失败部署、补充回滚步骤,并把结果同步展示在中间工作区。' },
],
},
},
{
title: 'AI 驱动的可观测性',
description: '利用 AI 智能分析全链路日志与性能指标,实时识别潜在异常并提供预测性洞察,保障系统平稳运行。',
guide: {
title: '可观测性控制台向导',
dismiss: '退出向导',
steps: [
{ text: '把可观测性控制台作为日志、指标与异常诊断的 AI 工作入口。' },
{ text: '打开 Observability 工作区,从日志分析、时间线梳理或事件复盘开始。', link: { url: '/panel/observability', label: '打开 Observability 控制台' } },
{ text: '右侧 X 助手保持打开,中间区域持续展示分析结果、修复建议与总结。' },
],
},
},
],
nextSteps: {

View File

@ -1,4 +1,4 @@
import { Database, History, Key, Rocket, Settings } from 'lucide-react'
import { Activity, Database, Key, Rocket, Settings } from 'lucide-react'
import type { DashboardExtension } from '../../types'
@ -6,10 +6,10 @@ export const infraExtension: DashboardExtension = {
id: 'builtin.infra',
meta: {
title: '基础设施管理',
description: '云基础设施、部署、资源与日志管理。',
description: '云基础设施、部署、资源与可观测性管理。',
version: '1.0.0',
author: 'Cloud-Neutral',
keywords: ['infrastructure', 'deployments', 'resources', 'logs'],
keywords: ['infrastructure', 'deployments', 'resources', 'observability'],
},
routes: [
{
@ -44,10 +44,10 @@ export const infraExtension: DashboardExtension = {
},
{
id: 'logs',
path: '/panel/logs',
label: 'Logs',
description: '系统流水与审计日志',
icon: History,
path: '/panel/observability',
label: 'Observability',
description: '监控、日志与 AI 分析',
icon: Activity,
loader: () => import('./routes/placeholder'),
guard: { requireLogin: true },
sidebar: { section: 'infra', order: 3 },

View File

@ -1,22 +1,368 @@
'use client'
"use client";
import React from 'react'
import { usePathname } from 'next/navigation'
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowRight,
Database,
KeyRound,
Rocket,
ShieldCheck,
Sparkles,
} from "lucide-react";
import {
OpenClawAssistantPane,
type OpenClawAssistantViewState,
} from "@/components/openclaw/OpenClawAssistantPane";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { cn } from "@/lib/utils";
type WorkspaceKind = "deployments" | "resources" | "logs" | "api-keys";
type WorkspaceConfig = {
title: string;
subtitle: string;
icon: LucideIcon;
accent: string;
prompts: string[];
suggestions: string[];
};
const EMPTY_DEFAULTS: IntegrationDefaults = {
openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false,
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "",
apisixTokenConfigured: false,
};
function getWorkspaceKind(pathname: string): WorkspaceKind {
if (pathname.includes("/resources")) {
return "resources";
}
if (pathname.includes("/observability")) {
return "logs";
}
if (pathname.includes("/api-keys")) {
return "api-keys";
}
return "deployments";
}
function getWorkspaceConfig(kind: WorkspaceKind): WorkspaceConfig {
switch (kind) {
case "resources":
return {
title: "Resources",
subtitle: "让 X 助手整理资源盘点、实例状态和依赖关系,结果在这里持续展开。",
icon: Database,
accent: "R",
prompts: [
"盘点当前资源并按环境分组",
"列出数据库实例与风险项",
"整理资源依赖关系和下一步动作",
],
suggestions: [
"资源清单",
"实例状态",
"风险摘要",
],
};
case "logs":
return {
title: "Observability",
subtitle: "把监控、日志与 AI 分析集中到同一个中间结果区,不再停留在空白页。",
icon: Activity,
accent: "O",
prompts: [
"分析最近异常日志并归类",
"总结监控异常和修复建议",
"按时间线梳理今天的可观测性事件",
],
suggestions: [
"指标概览",
"日志分析",
"修复建议",
],
};
case "api-keys":
return {
title: "API Keys",
subtitle: "把接口密钥、访问凭证和安全引用整理成可交互的工作区结果。",
icon: KeyRound,
accent: "K",
prompts: [
"梳理当前密钥用途与系统归属",
"列出需要轮换的访问凭证",
"生成密钥治理检查清单",
],
suggestions: [
"凭证盘点",
"轮换建议",
"治理清单",
],
};
default:
return {
title: "Deployments",
subtitle: "部署任务、发布状态和后续动作由 X 助手驱动,中间区直接展示结果流。",
icon: Rocket,
accent: "D",
prompts: [
"总结当前部署状态和阻塞项",
"生成一次部署检查清单",
"分析失败部署并给出修复步骤",
],
suggestions: [
"部署状态",
"阻塞项",
"执行步骤",
],
};
}
}
function StatusPill({
label,
tone = "neutral",
}: {
label: string;
tone?: "neutral" | "success" | "danger";
}) {
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-semibold",
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: tone === "danger"
? "border-rose-200 bg-rose-50 text-rose-700"
: "border-[color:var(--color-surface-border)] bg-white text-[var(--color-text-subtle)]",
)}
>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
tone === "success"
? "bg-emerald-500"
: tone === "danger"
? "bg-rose-500"
: "bg-[var(--color-primary)]",
)}
/>
{label}
</div>
);
}
export default function PlaceholderPage() {
const pathname = usePathname()
const title = pathname.split('/').pop()?.replace(/-/g, ' ') || 'Page'
const pathname = usePathname();
const kind = useMemo(() => getWorkspaceKind(pathname), [pathname]);
const config = useMemo(() => getWorkspaceConfig(kind), [kind]);
const Icon = config.icon;
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-8">
<div className="size-16 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-6">
<span className="text-2xl font-bold uppercase">{title.charAt(0)}</span>
const [defaults, setDefaults] = useState<IntegrationDefaults>(EMPTY_DEFAULTS);
const [promptSeed, setPromptSeed] = useState("");
const [promptKey, setPromptKey] = useState(0);
const [assistantState, setAssistantState] =
useState<OpenClawAssistantViewState>({
connectionState: "idle",
healthBadge: "offline",
errorMessage: "",
hasGateway: false,
selectedSessionLabel: "main",
streamingText: "",
streamingHtml: "",
messages: [],
});
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const response = await fetch("/api/integrations/defaults", {
cache: "no-store",
});
if (!response.ok) {
return;
}
const payload = (await response.json()) as IntegrationDefaults;
if (!cancelled) {
setDefaults(payload);
}
} catch {
// Keep empty defaults so the assistant can still render setup guidance.
}
})();
return () => {
cancelled = true;
};
}, []);
const assistantMessages = assistantState.messages.filter(
(message) => message.role === "user" || message.role === "assistant",
);
const statusTone =
assistantState.connectionState === "ready"
? "success"
: assistantState.connectionState === "error"
? "danger"
: "neutral";
return (
<div className="flex h-full min-h-[calc(100vh-11rem)] flex-col gap-5">
<div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
<section className="flex min-h-0 flex-col rounded-[26px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
<div className="border-b border-[color:var(--color-surface-border)] px-6 py-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-[18px] bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
<Icon className="h-6 w-6" />
</div>
<div className="text-5xl font-bold tracking-[-0.04em] text-[var(--color-primary)]">
{config.accent}
</div>
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.04em] text-[var(--color-heading)]">
{config.title}
</h1>
<p className="mt-3 max-w-3xl text-lg leading-8 text-[var(--color-text-subtle)]">
{config.subtitle}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<StatusPill label={assistantState.healthBadge} tone={statusTone} />
<StatusPill label={`Session · ${assistantState.selectedSessionLabel}`} />
</div>
</div>
<h1 className="text-3xl font-bold text-heading mb-3 capitalize">{title}</h1>
<p className="text-text-muted max-w-md">
This feature is currently under active development.
It will soon provide a powerful interface for managing your {title.toLowerCase()}.
</p>
</div>
)
<div className="mt-5 flex flex-wrap gap-3">
{config.prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => {
setPromptSeed(prompt);
setPromptKey((current) => current + 1);
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-white px-4 py-2 text-sm font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)]"
>
<Sparkles className="h-4 w-4" />
{prompt}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{!assistantState.hasGateway ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-6 text-center">
<ShieldCheck className="mb-4 h-10 w-10 text-[var(--color-primary)]" />
<h2 className="text-2xl font-semibold text-[var(--color-heading)]">
X
</h2>
<p className="mt-3 max-w-xl text-base leading-7 text-[var(--color-text-subtle)]">
Gateway
</p>
<Link
href="/panel/api"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-5 py-3 text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)]"
>
<ArrowRight className="h-4 w-4" />
</Link>
</div>
) : assistantMessages.length === 0 && !assistantState.streamingText ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-6 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-[20px] bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
<Sparkles className="h-8 w-8" />
</div>
<h2 className="text-2xl font-semibold text-[var(--color-heading)]">
X
</h2>
<p className="mt-3 max-w-2xl text-base leading-7 text-[var(--color-text-subtle)]">
使
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
{config.suggestions.map((item) => (
<div
key={item}
className="rounded-full border border-[color:var(--color-surface-border)] bg-white px-4 py-2 text-sm font-semibold text-[var(--color-text-subtle)]"
>
{item}
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
{assistantMessages.map((message) => {
const isUser = message.role === "user";
return (
<div
key={message.id}
className={cn("flex", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[88%] rounded-[22px] px-5 py-4 shadow-[var(--shadow-sm)]",
isUser
? "bg-[var(--color-primary)] text-[var(--color-primary-foreground)]"
: "border border-[color:var(--color-surface-border)] bg-white text-[var(--color-text)]",
)}
>
<div
className={cn(
"prose prose-sm max-w-none break-words whitespace-pre-wrap",
isUser ? "prose-invert" : "",
)}
dangerouslySetInnerHTML={{ __html: message.html }}
/>
</div>
</div>
);
})}
{assistantState.streamingText ? (
<div className="flex justify-start">
<div className="max-w-[88%] rounded-[22px] border border-[color:var(--color-surface-border)] bg-white px-5 py-4 shadow-[var(--shadow-sm)]">
<div
className="prose prose-sm max-w-none break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: assistantState.streamingHtml,
}}
/>
</div>
</div>
) : null}
</div>
)}
</div>
</section>
<aside className="min-h-0 rounded-[26px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
<OpenClawAssistantPane
defaults={defaults}
initialQuestion={promptSeed}
initialQuestionKey={promptKey}
variant="sidebar"
showConversation={false}
emptyConversationHint="在这里发起部署、资源或日志任务,中间区域会同步展示结果。"
onStateChange={setAssistantState}
/>
</aside>
</div>
</div>
);
}

View File

@ -18,7 +18,7 @@ const APISIX_URL_KEYS = [
'API_GATEWAY_URL',
] as const
const APISIX_TOKEN_KEYS = ['AI_GATEWAY_ACCESS_TOKEN'] as const
const APISIX_TOKEN_KEYS = ['AI_GATEWAY_ACCESS_TOKEN', 'AI_GATEWAY_API_KEY'] as const
const VAULT_URL_KEYS = ['VAULT_SERVER_URL', 'VAULT_ADDR', 'vault_addr'] as const
const VAULT_NAMESPACE_KEYS = ['VAULT_NAMESPACE'] as const
@ -257,7 +257,7 @@ export async function resolveApisixProbeConfig(overrides?: {
vaultToken: overrides?.vaultToken ?? readEnvValue(...VAULT_TOKEN_KEYS) ?? '',
vaultNamespace: overrides?.vaultNamespace ?? readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '',
vaultSecretPath: overrides?.vaultSecretPath,
fallbackKeys: ['AI_GATEWAY_ACCESS_TOKEN', 'APISIX_AI_GATEWAY_TOKEN'],
fallbackKeys: ['AI_GATEWAY_ACCESS_TOKEN', 'AI_GATEWAY_API_KEY', 'APISIX_AI_GATEWAY_TOKEN'],
})
return {