merge: resolve README.md conflict and unify insight features

This commit is contained in:
Haitao Pan 2026-02-02 04:19:03 +08:00
commit 83b46f4e76
57 changed files with 6377 additions and 69 deletions

View File

@ -1,13 +1,13 @@
#==============================================================#
# File : Makefile
# Desc : pigsty shortcuts
# Desc : observability shortcuts
# Ctime : 2019-04-13
# Mtime : 2026-01-27
# Mtime : 2026-02-01
# Path : Makefile
# License : Apache-2.0 @ https://pigsty.io/docs/about/license/
# License : Apache-2.0 @ https://svc.plus/docs/about/license/
# Copyright : 2018-2026 Ruohang Feng / Vonng (rh@vonng.com)
#==============================================================#
# pigsty version string
# version string
VERSION?=v4.0.0
# detect architecture
@ -68,53 +68,31 @@ doc:
docs/serve
#-------------------------------------------------------------#
# (1). BOOTSTRAP pigsty pkg & util preparedness
# Prepare
# (1). make deps (once) Install MacOS deps with homebrew
# (2). make dns (once) Write static DNS
# (3). make start (once) Pull-up vm nodes and setup ssh access
# (4). make demo Boot meta node same as Quick-Start
#=============================================================#
#-------------------------------------------------------------#
# (1). BOOTSTRAP util preparedness
boot: bootstrap
bootstrap:
./bootstrap
# (2). CONFIGURE pigsty in interactive mode
# (2). CONFIGURE interactive mode
conf: configure
configure:
./configure
# (3). DEPLOY pigsty on current node
# (3). DEPLOY install on current node
deploy:
./deploy.yml
###############################################################
###############################################################
# OUTLINE #
###############################################################
# (1). Quick-Start : shortcuts for launching pigsty (above)
# (2). Download : shortcuts for downloading resources
# (3). Configure : shortcuts for configure pigsty
# (4). Install : shortcuts for running playbooks
# (5). Sandbox : shortcuts for manage sandbox vm nodes
# (6). Testing : shortcuts for testing features
# (7). Develop : shortcuts for dev purpose
# (8). Release : shortcuts for release and publish
# (9). Misc : shortcuts for miscellaneous tasks
###############################################################
###############################################################
# 2. Download #
###############################################################
# There are two things that need to be downloaded:
# pigsty.tgz : source code
# pkg.tgz : offline rpm packages (optional)
#
# get latest stable version to ~/pigsty
src:
curl -SL https://github.com/pgsty/pigsty/releases/download/${VERSION}/${SRC_PKG} -o ~/pigsty.tgz
###############################################################
###############################################################
# 3. Configure #
###############################################################
@ -507,7 +485,7 @@ release-dba:
gd: get-dba
get-dba:
curl -fsSL "https://repo.pigsty.cc/dba/$(DBA_PKG)" | tar -xzf -
curl -fsSL "https://repo.svc.plus/dba/$(DBA_PKG)" | tar -xzf -
@echo "DBA package extracted to current directory"
ud: upload-dba

114
README.md
View File

@ -1,9 +1,9 @@
# Observability.svc.plus
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-green.svg)](LICENSE)
[![Base: Pigsty v4.0](https://img.shields.io/badge/Base-Pigsty_v4.0-blue)](https://github.com/pgsty/pigsty)
[![Status: Stable](https://img.shields.io/badge/Status-Stable-blue)](https://svc.plus)
**Observability.svc.plus** is an advanced observability platform based on [**Pigsty v4.0**](https://github.com/pgsty/pigsty), strictly following the **Apache 2.0** license.
**Observability.svc.plus** is an advanced observability platform strictly following the **Apache 2.0** license.
> **Focus**: Monitoring & Observability (监控/可观测). Integrating **OpenTelemetry (OTel)**, with future plans to incorporate **DeepFlow Agent** and other open-source **NPM** (Network Performance Monitoring) probes.
@ -13,7 +13,96 @@
## 🚀 快速开始
## 🛠️ Server Installation
### 一键安装 (默认)
默认安装最新稳定版 , 默认使用当前主机名作为域名
```bash
curl -fsSL https://raw.githubusercontent.com/cloud-neutral-toolkit/observability.svc.plus/main/scripts/server-install.sh | bash
```
### 指定版本与域名 (安装建议)
```bash
curl -fsSL https://raw.githubusercontent.com/cloud-neutral-toolkit/observability.svc.plus/main/scripts/server-install.sh \
| bash -s -- observability.svc.plus
```
## Features
- **Observability First**: SOTA monitoring for **PG** / **Infra** / **Node** based on **VictoriaMetrics**, **Grafana**, and **OpenTelemetry**.
- **OTel Integration**: Native support for **OpenTelemetry**, facilitating unified trace, metric, and log ingestion.
- **Future Ready**: Planned integration for **DeepFlow Agent** and other open-source **NPM** probes for deep network and application observability.
- **Reliable Base**: Robust self-healing **HA** clusters, **PITR**, and secure infrastructure.
- **Maintainable**: **One-Cmd Deploy**, **IaC** support, and easy customization.
- **Controllable**: Self-sufficient Cloud Neutral FOSS. Run on **bare Linux**.
You can even use exotic [**PG kernel forks**](https://svc.plus/docs/pgsql/kernel) as an in-place replacement and wrap it as a full RDS service:
| Kernel | Key Feature | Description |
|------------------------------------------------------------|:--------------------------------|------------------------------------------------|
| [PostgreSQL](https://svc.plus/docs/pgsql/kernel/postgres) | **Extension Overwhelming** | Vanilla PostgreSQL with 444 extensions |
| [Citus](https://svc.plus/docs/pgsql/kernel/citus) | **Horizontal Scaling** | Distributive PostgreSQL via native extension |
| [WiltonDB](https://svc.plus/docs/pgsql/kernel/babelfish) | **SQL Server Migration** | Microsoft SQL Server wire-compatibility |
| [IvorySQL](https://svc.plus/docs/pgsql/kernel/ivorysql) | **Oracle Migration** | Oracle Grammar and PL/SQL compatible |
| [OpenHalo](https://svc.plus/docs/pgsql/kernel/openhalo) | **MySQL Migration** | MySQL wire-protocol compatibility |
| [Percona](https://svc.plus/docs/pgsql/kernel/percona) | **Transparent Data Encryption** | Percona Distribution with pg_tde |
| [FerretDB](https://svc.plus/docs/ferret) | **MongoDB Migration** | MongoDB wire-protocol compatibility |
| [OrioleDB](https://svc.plus/docs/pgsql/kernel/orioledbdb) | **OLTP Optimization** | No bloat, No XID Wraparound, S3 Storage |
| [PolarDB](https://svc.plus/docs/pgsql/kernel/polardb) | **Aurora flavor RAC** | RAC, China domestic compliance |
| [Supabase](https://svc.plus/docs/app/supabase) | **Backend as Service** | BaaS based on PostgreSQL, Firebase alternative |
And gather the synergistic superpowers of all [**444+ PostgreSQL Extensions**](https://pgext.cloud/list) all together:
[![ecosystem](https://github.com/user-attachments/assets/c952441e-5ff7-4acb-aace-dd3021d28622)](https://pgext.cloud)
## Get Started
[![Postgres: 18.1](https://img.shields.io/badge/PostgreSQL-18.1-%233E668F?style=flat&logo=postgresql&labelColor=3E668F&logoColor=white)](https://svc.plus/docs/pgsql)
[![Linux](https://img.shields.io/badge/Linux-AMD64-%23FCC624?style=flat&logo=linux&labelColor=FCC624&logoColor=black)](https://svc.plus/docs/node)
[![Linux](https://img.shields.io/badge/Linux-ARM64-%23FCC624?style=flat&logo=linux&labelColor=FCC624&logoColor=black)](https://svc.plus/docs/node)
[![EL Support: 8/9/10](https://img.shields.io/badge/EL-8/9/10-red?style=flat&logo=redhat&logoColor=red)](https://svc.plus/docs/ref/linux#el)
[![Debian Support: 12/13](https://img.shields.io/badge/Debian-12/13-%23A81D33?style=flat&logo=debian&logoColor=%23A81D33)](https://svc.plus/docs/ref/linux#debian)
[![Ubuntu Support: 22/24](https://img.shields.io/badge/Ubuntu-22/24-%23E95420?style=flat&logo=ubuntu&logoColor=%23E95420)](https://svc.plus/docs/ref/linux#ubuntu)
[![Docker Image](https://img.shields.io/badge/Docker-v4.0.0-%232496ED?style=flat&logo=docker&logoColor=white)](https://svc.plus/docs/setup/docker)
[**Prepare**](https://svc.plus/docs/deploy/prepare) a fresh `x86_64` / `aarch64` node runs any [**compatible**](https://svc.plus/docs/ref/linux) **Linux** OS Distros, then [**Install**](https://svc.plus/docs/setup/install#install) the platform with:
```bash
curl -fsSL https://raw.githubusercontent.com/cloud-neutral-toolkit/observability.svc.plus/main/scripts/server-install.sh | bash
```
Then [**configure**](https://svc.plus/docs/concept/iac/configure) and run the [**`deploy.yml`**](https://svc.plus/docs/setup/playbook) playbook with an [**admin user**](https://svc.plus/docs/deploy/admin) (**nopass** `ssh` & `sudo`):
```bash
./configure -g # generate config and random passwords
./deploy.yml # deploy everything on current node
```
Finally, you will get a [**singleton node ready**](https://svc.plus/docs/setup/install), with [**WebUI**](https://svc.plus/docs/setup/webui) on port `80/443` and [**Postgres**](https://svc.plus/docs/setup/pgsql) on port `5432`.
For dev/testing purposes, you can also run it inside [**Docker**](https://svc.plus/docs/setup/docker) containers: `cd docker; make launch`
--------
> [**Single-Node Setup**](https://svc.plus/docs/setup/install) | [**Production Deploy**](https://svc.plus/docs/deploy) | [**Offline Install**](https://svc.plus/docs/setup/offline) | [**Minimal Install**](https://svc.plus/docs/setup/slim) | [**Docker Install**](https://svc.plus/docs/setup/docker) | [**Run Supabase**](https://svc.plus/docs/app/supabase)
<details><summary>Install with the pig cli</summary><br>
Then you can launch pigsty with `pig sty` sub command:
```bash
curl -fsSL https://repo.pigsty.io/pig | bash # install pig
pig sty init # install latest pigsty src to ~/pigsty
pig sty conf # auto-generate pigsty.yml config file
pig sty deploy # run the deploy.yml playbook
```
</details>
## 🚀 快速开始
### 一键安装 (默认)
默认安装最新稳定版 , 默认使用当前主机名作为域名
@ -64,28 +153,13 @@ curl -fsSL https://raw.githubusercontent.com/cloud-neutral-toolkit/observability
## Architecture
Pigsty uses a [**modular**](https://svc.plus/docs/concept/arch) design. you can [**use one or all**](https://svc.plus/docs/deploy/planning), Best of breed products. Integrated as a platform.
[![board](https://pigsty.io/img/pigsty/motherboard.gif)](https://svc.plus/docs/concept/arch)
[![PGSQL](https://img.shields.io/badge/PGSQL-%233E668F?style=flat&logo=postgresql&labelColor=3E668F&logoColor=white)](https://svc.plus/docs/pgsql) Self-healing PostgreSQL HA cluster powered by Patroni, Pgbouncer, PgBackrest & HAProxy
Integrated as a platform.
[![INFRA](https://img.shields.io/badge/INFRA-%23009639?style=flat&logo=nginx&labelColor=009639&logoColor=white)](https://svc.plus/docs/infra) Nginx, Local Repo, DNSMasq, and the entire Victoria & Grafana observability stack.
[![NODE](https://img.shields.io/badge/NODE-%23FCC624?style=flat&logo=linux&labelColor=FCC624&logoColor=black)](https://svc.plus/docs/node) Init node name, repo, pkg, NTP, ssh, admin, tune, expose services, collect logs & metrics.
[![ETCD](https://img.shields.io/badge/ETCD-%23419EDA?style=flat&logo=etcd&labelColor=419EDA&logoColor=white)](https://svc.plus/docs/etcd) Etcd cluster is used as a reliable distributive configuration store by PostgreSQL HA Agents.
You can compose them freely in a declarative manner. `INFRA` & `NODE` will suffice for host monitoring.
`ETCD` and `PGSQL` are used for HA PG clusters; Installing them on multiple nodes automatically forms HA clusters.
The default [`deploy.yml`](https://github.com/pgsty/pigsty/blob/main/deploy.yml) playbook will deploy `INFRA`, `NODE`, `ETCD` & `PGSQL` on the current node.
The default [`deploy.yml`](deploy.yml) playbook will deploy `INFRA`, `NODE`, `ETCD` & `PGSQL` on the current node.
Which gives you an out-of-the-box PostgreSQL singleton instance (`admin_ip:5432`) with everything ready.
[![pigsty-arch](https://pigsty.io/img/pigsty/arch.png)](https://svc.plus/docs/concept/arch)
The node can be used as an admin controller to deploy & monitor more nodes & clusters. For example, you can install these **6** **OPTIONAL** [extra modules](https://svc.plus/docs/ref/module#extra-modules) for advanced use cases:
[![MinIO](https://img.shields.io/badge/MINIO-%23C72E49?style=flat&logo=minio&logoColor=white)](https://svc.plus/docs/minio) S3-compatible object storage service; used as an optional central backup server for `PGSQL`.

View File

@ -43,6 +43,8 @@
- { role: infra ,tags: infra } # setup infra components
# node-monitor
- { role: node_monitor ,tags: monitor } # init node exporter & vector
# insight
- { role: insight ,tags: insight } # setup insight workbench
#--------------------------------------------------------------#

View File

@ -2,18 +2,18 @@
#-----------------------------------------------------------------
# INFRA_META
#-----------------------------------------------------------------
version: v4.0.0 # pigsty version string
version: v4.0.0 # version string
admin_ip: 10.10.10.10 # admin node ip address, overwritten by configure
region: default # upstream mirror region: default,china,europe
language: en # default language, en by default, could be zh
proxy_env: { no_proxy: "localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16,*.pigsty,*.aliyun.com,mirrors.*,*.myqcloud.com,*.tsinghua.edu.cn" }
proxy_env: { no_proxy: "localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16,*.aliyun.com,mirrors.*,*.myqcloud.com,*.tsinghua.edu.cn" }
#-----------------------------------------------------------------
# INFRA_IDENTITY
#-----------------------------------------------------------------
#infra_seq: 1 # infra node identity, explicitly required
infra_portal: # infra services exposed via portal
home : { domain: i.pigsty } # default home server definition
home : { domain: i.observability } # default home server definition
infra_domain: observability.svc.plus
infra_data: /data/infra # default data path for infrastructure data
infra_services: # home page navigation entries
@ -23,8 +23,8 @@ infra_services: # home page navigation entries
- { name: Monitor Targets ,url: '/vmetrics/targets' ,desc: 'Prometheus Scrape Targets' ,icon: 'target' ,name_cn: '监控目标' ,desc_cn: 'VictoriaMetrics 监控对象列表' }
- { name: Alert Rules ,url: '/vmalert/vmalert/groups' ,desc: 'VMAlert alert/record Rules' ,icon: 'alert' ,name_cn: '告警规则' ,desc_cn: 'VMAlert 告警规则管理' }
- { name: Alert Manager ,url: '/alertmgr/#/alerts' ,desc: 'Alert Manage & Silence' ,icon: 'alertmgr' ,name_cn: '告警管理' ,desc_cn: 'AlertManager 告警管理与屏蔽' }
- { name: CA Certificate ,url: '/ca.crt' ,desc: 'Self-Signed CA Certificate' ,icon: 'lock' ,name_cn: 'CA 证书' ,desc_cn: 'Pigsty 自签CA根证书' }
- { name: Software Repo ,url: '/pigsty' ,desc: 'Local YUM/APT Repository' ,icon: 'package' ,name_cn: '软件仓库' ,desc_cn: '本地 YUM/APT 软件源' }
- { name: CA Certificate ,url: '/ca.crt' ,desc: 'Self-Signed CA Certificate' ,icon: 'lock' ,name_cn: 'CA 证书' ,desc_cn: '自签CA根证书' }
- { name: Software Repo ,url: '/repo' ,desc: 'Local YUM/APT Repository' ,icon: 'package' ,name_cn: '软件仓库' ,desc_cn: '本地 YUM/APT 软件源' }
- { name: Explain Visualizer ,url: '/pev' ,desc: 'Postgres EXPLAIN Visualizer' ,icon: 'search' ,name_cn: '执行计划' ,desc_cn: 'PG 执行计划可视化工具' }
infra_extra_services: [] # extra services to be added on infra home page
@ -107,7 +107,7 @@ grafana_enabled: true # enable grafana on this infra node?
grafana_port: 3000 # default listen port for grafana
grafana_clean: false # clean grafana data during init?
grafana_admin_username: admin # grafana admin username, `admin` by default
grafana_admin_password: pigsty # grafana admin password, `pigsty` by default
grafana_admin_password: observability # grafana admin password, `observability` by default
grafana_auth_proxy: false # enable grafana auth proxy?
grafana_pgurl: '' # external postgres database url for grafana if given
grafana_view_password: DBUser.Viewer # password for grafana meta pg datasource

View File

@ -23,6 +23,13 @@
reverse_proxy 127.0.0.1:4318
}
# -------------------------
# Insight Workbench
# -------------------------
handle_path /insight/* {
reverse_proxy 127.0.0.1:{{ workbench_port | default('8080') }}
}
# -------------------------
# Grafana: /ui/ /ui/api/live/
# -------------------------
@ -127,8 +134,11 @@
root * /www
file_server browse
@home path /
redir @home /insight 301
@zh path /zh
rewrite @zh /zh.html
redir @zh /insight 301
@pev path /pev
rewrite @pev /pev.html

View File

@ -28,6 +28,16 @@ server {
proxy_buffering off;
proxy_request_buffering off;
# insight workbench
location /insight/ {
auth_basic off;
proxy_pass http://127.0.0.1:{{ workbench_port|default('8080') }};
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# grafana
location /ui/ {
auth_basic off;
@ -135,8 +145,7 @@ server {
# chinese homepage
location = /zh {
alias {{ upstream.path|default(nginx_home|default('/www')) }}/zh.html;
default_type text/html;
return 301 /insight;
}
# postgres explain visualizer
@ -154,8 +163,7 @@ server {
# home server
location = / {
root {{ upstream.path|default(nginx_home|default('/www')) }};
default_type text/html;
return 301 /insight;
}
# home server

View File

@ -0,0 +1,17 @@
---
#-----------------------------------------------------------------
# INSIGHT - Observability Workbench
#-----------------------------------------------------------------
insight_enabled: true # enable insight workbench?
workbench_port: 8080 # workbench listen port
workbench_dir: /data/workbench # workbench deployment directory
workbench_user: "{{ node_user | default('root') }}"
workbench_group: "{{ node_user | default('root') }}"
# nodejs version and registry
nodejs_version: 20
nodejs_registry: https://registry.npmmirror.com
# build options
workbench_clean: false # clean workbench directory before deployment?
workbench_build: true # run npm install & build during setup?

View File

@ -0,0 +1,81 @@
---
#--------------------------------------------------------------#
# INSIGHT - Observability Workbench Deployment
#--------------------------------------------------------------#
- name: install nodejs and build tools
tags: insight_pkg
package:
name:
- nodejs
- npm
- gcc-c++
- make
state: present
- name: create workbench directory
tags: insight_dir
file:
path: "{{ workbench_dir }}"
state: directory
owner: "{{ workbench_user }}"
group: "{{ workbench_group }}"
mode: '0755'
- name: sync workbench source code
tags: insight_sync
synchronize:
src: "{{ playbook_dir }}/workbench/"
dest: "{{ workbench_dir }}/"
delete: yes
recursive: yes
rsync_opts:
- "--exclude=.next"
- "--exclude=node_modules"
- "--exclude=.git"
- name: install npm dependencies
tags: insight_build
when: workbench_build|bool
npm:
path: "{{ workbench_dir }}"
registry: "{{ nodejs_registry }}"
environment: "{{ proxy_env | default({}) }}"
- name: build nextjs workbench
tags: insight_build
when: workbench_build|bool
shell: npm run build
args:
chdir: "{{ workbench_dir }}"
executable: /bin/bash
environment:
PATH: "/usr/local/bin:/usr/bin:/bin"
NODE_ENV: production
- name: render insight systemd service
tags: insight_config
template:
src: insight.service.j2
dest: /etc/systemd/system/insight.service
owner: root
group: root
mode: '0644'
- name: launch insight service
tags: insight_launch
systemd:
name: insight
state: restarted
enabled: yes
daemon_reload: yes
- name: wait for insight workbench
tags: insight_launch
wait_for:
host: 127.0.0.1
port: "{{ workbench_port }}"
state: started
timeout: 60
...

View File

@ -0,0 +1,17 @@
[Unit]
Description=Insight Workbench Service
After=network.target
[Service]
Type=simple
User={{ workbench_user }}
Group={{ workbench_group }}
WorkingDirectory={{ workbench_dir }}
Environment=PORT={{ workbench_port }}
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

10
workbench/.env.local Normal file
View File

@ -0,0 +1,10 @@
# Moltbot Service URL
# Defaults to https://moltbot.svc.plus if not set
MOLTBOT_SERVICE_URL=https://moltbot.svc.plus
# Giscus Configuration (GitHub Discussions Integration)
# See https://giscus.app to generate these values
NEXT_PUBLIC_GISCUS_REPO=cloud-neutral-toolkit/console.svc.plus
NEXT_PUBLIC_GISCUS_REPO_ID=R_kgDOQoiZ_g
NEXT_PUBLIC_GISCUS_CATEGORY=General
NEXT_PUBLIC_GISCUS_CATEGORY_ID=DIC_kwDOQoiZ_s4Clj_q

125
workbench/next.config.mjs Normal file
View File

@ -0,0 +1,125 @@
import path from "path";
import { fileURLToPath } from "url";
import { withContentlayer } from "next-contentlayer";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const nextConfig = {
// ===============================
// 🚀 生产优化 —— 最关键的三行
// ===============================
output: "standalone", // 让 Next.js 生成可独立运行的最小产物(大幅减小 Docker 镜像)
compress: true, // Gzip 压缩输出(确保小体积网络传输)
basePath: "/insight", // 👈 所有的路由都会带有 /insight 前缀
// 配置允许的外部图片域名
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'dl.svc.plus',
},
{
protocol: 'https',
hostname: 'www.svc.plus',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
webpack: (config) => {
// 添加 YAML 文件支持
config.module.rules.push({
test: /\.ya?ml$/i,
type: 'asset/source',
});
// 显式 alias保证 Turbopack 也能解析
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@components": path.join(__dirname, "src", "components"),
"@i18n": path.join(__dirname, "src", "i18n"),
"@lib": path.join(__dirname, "src", "lib"),
"@types": path.join(__dirname, "types"),
"@server": path.join(__dirname, "src", "server"),
"@modules": path.join(__dirname, "src", "modules"),
"@extensions": path.join(__dirname, "src", "modules", "extensions"),
"@theme": path.join(__dirname, "src", "components", "theme"),
"@templates": path.join(__dirname, "src", "modules", "templates"),
"@src": path.join(__dirname, "src"),
"@": path.join(__dirname, "src"),
};
// 添加模块搜索路径
config.resolve.modules = [
...(config.resolve.modules || []),
__dirname,
path.join(__dirname, "src"),
];
return config;
},
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: process.env.CORS_ALLOWED_ORIGINS || "https://console.svc.plus,http://localhost:3000" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,PATCH,DELETE,OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization, X-Requested-With, X-Account-Session" },
],
},
];
},
reactStrictMode: true,
typedRoutes: false,
turbopack: {
root: path.resolve(__dirname),
},
};
export async function redirects() {
return [
{
source: '/XStream',
destination: '/xstream',
permanent: true,
},
{
source: '/Xstream',
destination: '/xstream',
permanent: true,
},
{
source: '/XScopeHub',
destination: '/xscopehub',
permanent: true,
},
{
source: '/XCloudFlow',
destination: '/xcloudflow',
permanent: true,
},
];
}
export async function rewrites() {
return [
{
source: '/editor',
destination: 'http://localhost:4000',
},
{
source: '/editor/:path*',
destination: 'http://localhost:4000/:path*',
},
];
}
export default withContentlayer(nextConfig);

107
workbench/package.json Normal file
View File

@ -0,0 +1,107 @@
{
"name": "dashboard",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=18.17 <25"
},
"scripts": {
"dev": "bash scripts/Dev-MCP-Server.sh && next dev --turbo",
"prebuild": "bash scripts/prebuild.sh",
"build": "next build",
"build:static": "npm run prebuild && next build",
"start": "node ./scripts/start.js",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"preview": "next build && next start",
"test": "vitest run --config tests/unit/vitest.config.ts",
"test:unit": "vitest run --config tests/unit/vitest.config.ts",
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.956.0",
"@floating-ui/dom": "^1.6.0",
"@giscus/react": "^3.1.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@tiptap/core": "^3.13.0",
"@tiptap/pm": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@vercel/analytics": "^1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"contentlayer": "^0.3.4",
"dompurify": "^3.2.6",
"gray-matter": "^4.0.3",
"html2canvas": "^1.4.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"katex": "^0.16.27",
"lucide-react": "^0.319.0",
"marked": "^16.1.2",
"mermaid": "^11.12.2",
"next": "^16.0.9",
"next-contentlayer": "^0.3.4",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.4.6",
"pdfjs-dist": "^4.2.67",
"prismjs": "^1.30.0",
"prop-types": "^15.8.1",
"qr.js": "0.0.0",
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-pdf": "^9.1.0",
"react-resizable": "^3.0.4",
"sanitize-html": "^2.13.0",
"swr": "^2.3.0",
"tailwind-merge": "^3.4.0",
"zustand": "^4.5.4"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^9.3.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/js-yaml": "^4.0.9",
"@types/mdx": "^2.0.13",
"@types/node": "24.0.3",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.2.0",
"@types/react-grid-layout": "^1.3.5",
"@types/sanitize-html": "^2.16.0",
"autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.8.32",
"eslint": "8.57.0",
"eslint-config-next": "^15.5.3",
"jsdom": "^24.0.0",
"postcss": "^8.4.32",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.3",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"tsx": "^4.7.1",
"typescript": "^5.4.2",
"vitest": "^4.0.7"
},
"resolutions": {
"glob": "10.5.0",
"@opentelemetry/api": "1.4.1"
},
"packageManager": "yarn@4.12.0"
}

View File

@ -0,0 +1,14 @@
/**
* PostCSS 配置文件
* 使用 ES Module 格式 - 统一现代标准
*
* 参考: https://postcss.org/
*/
export default {
// 插件列表
plugins: {
autoprefixer: {},
tailwindcss: {},
},
}

View File

@ -0,0 +1,75 @@
@import 'react-grid-layout/css/styles.css';
@import 'react-resizable/css/styles.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-geist-sans: 'Geist', sans-serif;
--font-geist-mono: 'Geist Mono', monospace;
--app-shell-nav-offset: 5.5rem;
/* Light theme defaults */
--color-background: #f4f6fb;
--color-background-muted: #e7ecf6;
--color-surface: #ffffff;
--color-surface-elevated: rgba(255, 255, 255, 0.96);
--color-surface-translucent: rgba(255, 255, 255, 0.88);
--color-surface-muted: #f1f4fb;
--color-surface-hover: #f0f4ff;
--color-surface-border: #d6e0ff;
--color-surface-border-strong: #b4c5ff;
--color-text: #1e2e55;
--color-heading: #2e3a59;
--color-text-muted: #4a5672;
--color-text-subtle: #61708c;
--color-text-inverse: #f8fbff;
--color-primary: #3366ff;
--color-primary-hover: #4d7aff;
--color-primary-muted: #f0f4ff;
--color-primary-border: #d6e0ff;
--color-primary-foreground: #ffffff;
--color-accent: #254edb;
--color-accent-muted: #e3e9ff;
--color-accent-foreground: #162a6b;
--color-success: #16a34a;
--color-success-muted: #dcfce7;
--color-success-foreground: #166534;
--color-warning: #f59e0b;
--color-warning-muted: #fef3c7;
--color-warning-foreground: #92400e;
--color-danger: #ef4444;
--color-danger-muted: #fee2e2;
--color-danger-foreground: #7f1d1d;
--color-info: #3366ff;
--color-info-muted: #f0f4ff;
--color-info-foreground: #254edb;
--color-overlay: rgba(30, 46, 85, 0.45);
--color-ring: #d6e0ff;
--color-focus: rgba(51, 102, 255, 0.35);
--color-divider: rgba(15, 23, 42, 0.08);
--color-badge-surface: #e5e7eb;
--color-badge-muted: #f3f4f6;
--color-badge-foreground: #1f2937;
--gradient-app-from: #f5f8ff;
--gradient-app-via: #eef3ff;
--gradient-app-to: #f4f9ff;
--gradient-primary-from: #3366ff;
--gradient-primary-to: #254edb;
--shadow-sm: 0 1px 3px rgba(30, 46, 85, 0.08), 0 1px 2px rgba(30, 46, 85, 0.04);
--shadow-md: 0 12px 32px rgba(30, 46, 85, 0.12);
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-pill: 999px;
}
body {
font-family: var(--font-geist-sans);
background-color: var(--color-background);
color: var(--color-text);
transition: background-color 150ms ease, color 150ms ease;
}

View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { LanguageProvider } from "../i18n/LanguageProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Observability Workbench",
description: "Insight & Monitoring Dashboard",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LanguageProvider>
{children}
</LanguageProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,7 @@
"use client";
import InsightWorkbench from "@/components/insight/InsightWorkbench";
export default function Home() {
return <InsightWorkbench />;
}

View File

@ -0,0 +1,375 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Layout } from 'react-grid-layout'
import { ChevronLeft, ChevronRight, PanelLeftOpen } from 'lucide-react'
import { Sidebar } from './layout/Sidebar'
import { WorkspaceHeader } from './layout/WorkspaceHeader'
import { BreadcrumbBar } from './layout/BreadcrumbBar'
import { WorkspaceGrid } from './layout/WorkspaceGrid'
import { NetworkTopologyPanel } from './topology/NetworkTopologyPanel'
import { ExploreBuilder, languageMeta } from './explore/ExploreBuilder'
import { VizArea } from './viz/VizArea'
import { SLOPanel } from './slo/SLOPanel'
import { AIAssistant } from './ai/Assistant'
import { useInsightStore } from './store/useInsightState'
import { DataSource, QueryLanguage } from './store/urlState'
const LAYOUT_STORAGE_KEY = 'insight-workspace-layout-v1'
const DEFAULT_LAYOUT: Layout[] = [
{ i: 'network', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'promql', x: 6, y: 0, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'logql', x: 0, y: 8, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'traceql', x: 6, y: 8, w: 6, h: 8, minW: 4, minH: 6 }
]
export default function InsightWorkbench() {
const state = useInsightStore((store) => store.state)
const updateState = useInsightStore((store) => store.updateInsight)
const shareableLink = useInsightStore((store) => store.shareableLink)
const [activeSection, setActiveSection] = useState('topology')
const [history, setHistory] = useState<Record<QueryLanguage, string[]>>({
promql: [],
logql: [],
traceql: []
})
const [resultData, setResultData] = useState<Record<QueryLanguage, any>>({
promql: [],
logql: [],
traceql: []
})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [sidebarHidden, setSidebarHidden] = useState(false)
const [panelLayout, setPanelLayout] = useState<Layout[]>(() => DEFAULT_LAYOUT.map(item => ({ ...item })))
const [layoutDirty, setLayoutDirty] = useState(false)
const [layoutStatus, setLayoutStatus] = useState<string | null>(null)
const [detailsCollapsed, setDetailsCollapsed] = useState(false)
const statusTimeout = useRef<number | null>(null)
const handleSelectSection = useCallback((section: string) => {
setActiveSection(section)
const el = document.getElementById(section)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
const updateHistory = useCallback(
(language: QueryLanguage, items: string[]) => {
setHistory(prev => ({ ...prev, [language]: items }))
},
[]
)
const updateResults = useCallback((language: QueryLanguage, data: any) => {
setResultData(prev => ({ ...prev, [language]: data }))
}, [])
const toggleLanguage = useCallback(
(language: QueryLanguage) => {
const exists = state.activeLanguages.includes(language)
let nextActive = exists
? state.activeLanguages.filter(item => item !== language)
: [...state.activeLanguages, language]
if (nextActive.length === 0) {
nextActive = [language]
}
const primary = nextActive[0]
const nextSource: DataSource = primary === 'promql' ? 'metrics' : primary === 'logql' ? 'logs' : 'traces'
updateState({
activeLanguages: nextActive,
queryLanguage: primary,
dataSource: nextSource
})
},
[state.activeLanguages, updateState]
)
const handleLayoutChange = useCallback((next: Layout[]) => {
setPanelLayout(next)
setLayoutDirty(true)
}, [])
const resetStatusMessage = useCallback(() => {
if (statusTimeout.current) {
window.clearTimeout(statusTimeout.current)
statusTimeout.current = null
}
}, [])
const handleSaveLayout = useCallback(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(panelLayout))
setLayoutDirty(false)
setLayoutStatus('Layout saved locally')
resetStatusMessage()
statusTimeout.current = window.setTimeout(() => setLayoutStatus(null), 2200)
}, [panelLayout, resetStatusMessage])
const handleResetLayout = useCallback(() => {
setPanelLayout(DEFAULT_LAYOUT.map(item => ({ ...item })))
setLayoutDirty(false)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(LAYOUT_STORAGE_KEY)
}
setLayoutStatus('Layout reset to default')
resetStatusMessage()
statusTimeout.current = window.setTimeout(() => setLayoutStatus(null), 2200)
}, [resetStatusMessage])
useEffect(() => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(LAYOUT_STORAGE_KEY)
if (!stored) return
try {
const parsed = JSON.parse(stored) as Layout[]
if (!Array.isArray(parsed)) return
const merged = DEFAULT_LAYOUT.map(item => {
const match = parsed.find(entry => entry.i === item.i)
return match ? { ...item, ...match } : { ...item }
})
setPanelLayout(merged)
setLayoutDirty(false)
} catch (error) {
console.error('Failed to restore insight layout', error)
}
}, [])
useEffect(() => {
return () => {
if (statusTimeout.current) {
window.clearTimeout(statusTimeout.current)
}
}
}, [])
const keyMetrics = useMemo(
() => [
{
label: 'Availability',
value: state.topologyMode === 'network' ? '99.96%' : '99.90%',
trend: '+0.3% vs last 7d',
tone: 'positive' as const
},
{
label: 'P95 latency',
value: state.topologyMode === 'network' ? '82 ms' : '248 ms',
trend: `${state.timeRange} window`,
tone: 'neutral' as const
},
{
label: 'Error rate',
value: state.topologyMode === 'application' ? '0.7%' : '0.4%',
trend: 'Target < 1%',
tone: 'warning' as const
}
],
[state.timeRange, state.topologyMode]
)
const explorerPanels = (language: QueryLanguage, domId?: string) => {
const enabled = state.activeLanguages.includes(language)
return {
id: language,
domId,
minW: 4,
minH: 6,
content: enabled ? (
<ExploreBuilder
state={state}
updateState={updateState}
history={history}
setHistory={updateHistory}
onResults={updateResults}
panelLanguages={[language]}
/>
) : (
<DisabledExplorerCard language={language} onEnable={() => toggleLanguage(language)} />
)
}
}
const panels = [
{
id: 'network',
domId: 'topology',
minW: 4,
minH: 6,
content: <NetworkTopologyPanel state={state} updateState={updateState} />
},
explorerPanels('promql', 'explore'),
explorerPanels('logql'),
explorerPanels('traceql')
]
const insightAsideWidth = detailsCollapsed ? 'lg:w-60 xl:w-64' : 'lg:w-80 xl:w-96'
return (
<div className="relative flex min-h-screen flex-col bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
{sidebarHidden && (
<button
type="button"
onClick={() => setSidebarHidden(false)}
className="fixed left-4 top-4 z-20 flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 shadow-lg backdrop-blur transition hover:border-slate-700 hover:text-slate-100"
>
<PanelLeftOpen className="h-4 w-4" />
Show menu
</button>
)}
<header className="flex-shrink-0 border-b border-slate-800 bg-slate-950/70">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<BreadcrumbBar state={state} updateState={updateState} shareableLink={shareableLink} />
<div className="flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
onClick={handleSaveLayout}
disabled={!layoutDirty}
className={`rounded-xl px-3 py-2 font-medium transition ${layoutDirty
? 'border border-emerald-500/60 bg-emerald-500/10 text-emerald-200 hover:border-emerald-400/80'
: 'border border-slate-800 bg-slate-900/70 text-slate-500'
}`}
>
Save layout
</button>
<button
type="button"
onClick={handleResetLayout}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 font-medium text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
>
Reset to default
</button>
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{!sidebarHidden && (
<div className="flex-shrink-0">
<Sidebar
topologyMode={state.topologyMode}
activeLanguages={state.activeLanguages}
activeSection={activeSection}
onSelectSection={handleSelectSection}
onTopologyChange={mode => updateState({ topologyMode: mode })}
onToggleLanguage={toggleLanguage}
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
onHide={() => setSidebarHidden(true)}
collapsed={sidebarCollapsed}
/>
</div>
)}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto px-4 py-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
<main className="flex-1 space-y-6">
<WorkspaceHeader
state={state}
updateState={updateState}
shareableLink={shareableLink}
statusMessage={layoutStatus}
showBreadcrumb={false}
/>
<div id="explore">
<WorkspaceGrid
layout={panelLayout}
defaultLayout={DEFAULT_LAYOUT}
panels={panels}
onLayoutChange={handleLayoutChange}
draggableHandle=".panel-drag-handle"
/>
</div>
<section id="visualize">
<VizArea state={state} data={resultData[state.queryLanguage]} onUpdate={updateState} />
</section>
</main>
<aside
className={`mt-6 w-full flex-shrink-0 lg:mt-0 ${insightAsideWidth} lg:self-stretch lg:overflow-y-auto`}
>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<button
type="button"
onClick={() => setDetailsCollapsed(prev => !prev)}
className="flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-1 text-xs text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
>
{detailsCollapsed ? (
<>
<ChevronLeft className="h-4 w-4" /> Expand insights
</>
) : (
<>
Collapse insights <ChevronRight className="h-4 w-4" />
</>
)}
</button>
</div>
{detailsCollapsed ? (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 text-xs text-slate-300">
<h3 className="text-sm font-semibold text-slate-100">Key health metrics</h3>
<p className="text-[11px] text-slate-500">Pinned while the panel is collapsed for quick status checks.</p>
<div className="grid gap-3">
{keyMetrics.map(metric => (
<div
key={metric.label}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2"
>
<p className="text-[11px] uppercase tracking-wide text-slate-500">{metric.label}</p>
<p className="text-lg font-semibold text-slate-100">{metric.value}</p>
<p
className={`text-[11px] ${metric.tone === 'positive'
? 'text-emerald-300'
: metric.tone === 'warning'
? 'text-amber-300'
: 'text-slate-400'
}`}
>
{metric.trend}
</p>
</div>
))}
</div>
</div>
) : (
<div className="space-y-6">
<section id="slo">
<SLOPanel state={state} />
</section>
<section id="ai">
<AIAssistant state={state} />
</section>
</div>
)}
</div>
</aside>
</div>
</div>
</div>
</div>
</div>
)
}
interface DisabledExplorerCardProps {
language: QueryLanguage
onEnable: () => void
}
function DisabledExplorerCard({ language, onEnable }: DisabledExplorerCardProps) {
const meta = languageMeta[language]
return (
<section className="flex h-full flex-col rounded-2xl border border-dashed border-slate-800 bg-slate-900/40 p-5">
<header className="panel-drag-handle mb-4">
<h3 className="text-sm font-semibold text-slate-200">{meta.label}</h3>
<p className="text-xs text-slate-400">Enable this explorer from the navigation to build queries.</p>
</header>
<div className="flex flex-1 flex-col items-start justify-center gap-4 text-sm text-slate-400">
<p>Capture metrics, logs or traces by toggling the language on the left-hand menu.</p>
<button
type="button"
onClick={onEnable}
className="rounded-xl border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs font-medium text-emerald-200 transition hover:border-emerald-400/80"
>
Enable {meta.label.split(' ')[0]}
</button>
</div>
</section>
)
}

View File

@ -0,0 +1,225 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
const quickActions = [
{ id: 'explain', label: 'Explain anomaly', prompt: 'Explain the recent anomaly in my metrics.' },
{ id: 'logs', label: 'Fetch related logs', prompt: 'Show me logs correlated with the current filters.' },
{ id: 'rca', label: 'Root cause analysis', prompt: 'Run an RCA for the checkout service using metrics, logs and traces.' },
{ id: 'alert', label: 'Draft alert', prompt: 'Generate an alert rule for p95 latency over 300ms.' },
{ id: 'report', label: 'Incident report', prompt: 'Create a short incident report for the last hour.' }
]
interface AssistantProps {
state: InsightState
}
export function AIAssistant({ state }: AssistantProps) {
const [isOpen, setIsOpen] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const [isMaximized, setIsMaximized] = useState(false)
const [message, setMessage] = useState('')
const [history, setHistory] = useState<{ question: string; timestamp: number }[]>([])
const [conversation, setConversation] = useState<
{ author: 'user' | 'ai'; text: string; timestamp: number }[]
>([])
const contextSummary = useMemo(
() =>
`Org=${state.org}, Project=${state.project}, Env=${state.env}, Region=${state.region}, Topology=${state.topologyMode}, Service=${state.service || 'all'}, Time=${state.timeRange}`,
[state]
)
function openPanel(prompt?: string) {
setIsOpen(true)
setIsMinimized(false)
if (prompt) {
appendMessage(prompt)
}
}
async function appendMessage(prompt: string) {
const timestamp = Date.now()
const updatedHistory = [{ question: prompt, timestamp }, ...history].slice(0, 10)
setHistory(updatedHistory)
// Optimistic update
setConversation(prev => [
...prev,
{ author: 'user', text: prompt, timestamp },
])
try {
// Include context in the prompt or as a separate field if the API supports it
// For now, we prepend it to the prompt to ensure the bot knows the current view context
const contextAwarePrompt = `Context: [${contextSummary}]\n\nQuestion: ${prompt}`
const response = await fetch('/api/moltbot/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: contextAwarePrompt })
})
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`)
}
const data = await response.json()
const reply = data.reply || data.message || JSON.stringify(data)
setConversation(prev => [
...prev,
{ author: 'ai', text: reply, timestamp: Date.now() }
])
} catch (error: any) {
setConversation(prev => [
...prev,
{ author: 'ai', text: `Sorry, I encountered an error: ${error.message}`, timestamp: Date.now() }
])
}
}
function handleSend() {
if (!message.trim()) return
appendMessage(message.trim())
setMessage('')
}
function toggleMinimize() {
setIsMinimized(prev => !prev)
}
function toggleMaximize() {
setIsMaximized(prev => !prev)
setIsMinimized(false)
}
function closePanel() {
setIsOpen(false)
setIsMinimized(false)
setIsMaximized(false)
}
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div>
<h3 className="text-sm font-semibold text-slate-100">AI Assistant</h3>
<p className="text-xs text-slate-400">Bring AskAI insights into your observability workflow.</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{quickActions.map(action => (
<button
key={action.id}
onClick={() => openPanel(action.prompt)}
className="rounded-2xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-left text-sm text-slate-200 transition hover:border-emerald-500/60"
>
{action.label}
</button>
))}
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-xs text-slate-300">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Current context</p>
<p className="mt-2 text-[12px] leading-relaxed text-slate-400">{contextSummary}</p>
</div>
<div className="space-y-2 text-xs text-slate-300">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Recent questions</p>
{history.length === 0 ? (
<p className="text-xs text-slate-500">Run a quick action or open the assistant to get started.</p>
) : (
<ul className="space-y-1">
{history.map(item => (
<li key={item.timestamp} className="flex items-center justify-between rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
<span className="text-slate-200">{item.question}</span>
<span className="text-slate-500">{new Date(item.timestamp).toLocaleTimeString()}</span>
</li>
))}
</ul>
)}
</div>
<button
onClick={() => openPanel('Help me explore the current observability context.')}
className="w-full rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950"
>
Open assistant
</button>
{isOpen && (
<div
className={`fixed right-6 z-40 flex flex-col rounded-3xl border border-emerald-500/30 bg-slate-950/95 shadow-2xl transition-all ${isMaximized ? 'top-6 bottom-6 w-[420px] lg:w-[460px]' : 'bottom-10 w-[360px] lg:w-[400px]'
} ${isMinimized ? 'h-14 overflow-hidden' : 'max-h-[85vh]'}`}
>
<div className="flex items-center justify-between border-b border-emerald-500/20 px-4 py-3">
<div>
<p className="text-sm font-semibold text-slate-100">AI copilot</p>
{!isMinimized && (
<p className="text-[11px] text-slate-400">Context aware responses for the current workspace.</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300">
<button onClick={toggleMinimize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
{isMinimized ? 'Restore' : 'Minimize'}
</button>
<button onClick={toggleMaximize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
{isMaximized ? 'Default size' : 'Maximize'}
</button>
<button onClick={closePanel} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
Close
</button>
</div>
</div>
{!isMinimized && (
<div className="flex-1 overflow-y-auto px-4 py-3">
{conversation.length === 0 ? (
<p className="text-xs text-slate-500">
Ask a question to start a conversation. The assistant replies with enriched placeholders until wired to
your backend.
</p>
) : (
<ul className="space-y-3">
{conversation.map(entry => (
<li
key={entry.timestamp}
className={`rounded-2xl px-3 py-2 text-sm ${entry.author === 'user'
? 'bg-emerald-500/10 text-emerald-100'
: 'bg-slate-900/80 text-slate-200'
}`}
>
<p className="text-xs uppercase tracking-wide text-slate-500">
{entry.author === 'user' ? 'You' : 'Assistant'} · {new Date(entry.timestamp).toLocaleTimeString()}
</p>
<p className="mt-1 text-sm leading-relaxed">{entry.text}</p>
</li>
))}
</ul>
)}
</div>
)}
{!isMinimized && (
<div className="border-t border-emerald-500/10 bg-slate-950/80 px-4 py-3">
<textarea
value={message}
onChange={event => setMessage(event.target.value)}
placeholder="Ask anything about this observability view…"
className="h-20 w-full rounded-2xl border border-slate-800 bg-slate-900/80 p-3 text-sm text-slate-200"
/>
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
<span>Responses include context automatically.</span>
<button
onClick={handleSend}
className="rounded-xl bg-emerald-500/80 px-3 py-1.5 text-xs font-semibold text-emerald-950"
>
Send
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,341 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { fetchPromQL } from '../../insight/services/adapters/prometheus'
import { fetchLogs } from '../../insight/services/adapters/logs'
import { fetchTraces } from '../../insight/services/adapters/traces'
import { DataSource, InsightState, QueryInputMode, QueryLanguage } from '../../insight/store/urlState'
import { QueryChips } from '@components/common/QueryChips'
import { QueryHistoryPanel } from '@components/common/QueryHistoryPanel'
interface ExploreBuilderProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
history: Record<QueryLanguage, string[]>
setHistory: (language: QueryLanguage, next: string[]) => void
onResults: (language: QueryLanguage, data: any) => void
panelLanguages?: QueryLanguage[]
}
export const languageMeta: Record<
QueryLanguage,
{ label: string; description: string; dataSource: DataSource; placeholder: string }
> = {
promql: {
label: 'PromQL Explorer',
description: 'Build metrics queries for service SLOs and alerts.',
dataSource: 'metrics',
placeholder: 'sum(rate(http_requests_total{job="api"}[5m]))'
},
logql: {
label: 'LogQL Explorer',
description: 'Stream and filter structured application logs.',
dataSource: 'logs',
placeholder: '{service="checkout"} |= "error"'
},
traceql: {
label: 'TraceQL Explorer',
description: 'Slice and dice distributed tracing data.',
dataSource: 'traces',
placeholder: 'traces{service="checkout"} | duration > 250ms'
}
}
const defaultRecord = <T,>(value: T): Record<QueryLanguage, T> => ({
promql: value,
logql: value,
traceql: value
})
export function ExploreBuilder({
state,
updateState,
history,
setHistory,
onResults,
panelLanguages
}: ExploreBuilderProps) {
const [chipsMap, setChipsMap] = useState<Record<QueryLanguage, string[]>>(defaultRecord<string[]>([]))
const [runningMap, setRunningMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
const [messageMap, setMessageMap] = useState<Record<QueryLanguage, string>>(defaultRecord<string>(''))
const [collapsedMap, setCollapsedMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
const [inputModeMap, setInputModeMap] = useState<Record<QueryLanguage, QueryInputMode>>(defaultRecord<QueryInputMode>('ql'))
const activePanels = useMemo(
() => panelLanguages ?? state.activeLanguages,
[panelLanguages, state.activeLanguages]
)
useEffect(() => {
if (!state.service) return
setChipsMap(prev => {
const serviceChip = `service="${state.service}"`
const next: Record<QueryLanguage, string[]> = { ...prev }
activePanels.forEach(language => {
if (!next[language]) {
next[language] = []
}
if (!next[language].includes(serviceChip)) {
next[language] = [...next[language], serviceChip]
}
})
return next
})
}, [activePanels, state.service])
useEffect(() => {
setInputModeMap(prev => {
const next = { ...prev }
activePanels.forEach(language => {
if (!next[language]) {
next[language] = 'ql'
}
})
return next
})
}, [activePanels])
async function runQuery(language: QueryLanguage) {
const query = state.queries[language] || ''
if (!query) return
const meta = languageMeta[language]
setRunningMap(prev => ({ ...prev, [language]: true }))
setMessageMap(prev => ({ ...prev, [language]: '' }))
try {
let result: any
if (meta.dataSource === 'metrics') {
result = await fetchPromQL(query)
} else if (meta.dataSource === 'logs') {
result = await fetchLogs(query)
} else {
result = await fetchTraces(query)
}
onResults(language, result)
const nextHistory = [query, ...(history[language] || []).filter(item => item !== query)].slice(0, 15)
setHistory(language, nextHistory)
setMessageMap(prev => ({ ...prev, [language]: 'Query executed successfully' }))
} catch (err) {
console.error(err)
setMessageMap(prev => ({ ...prev, [language]: 'Query failed. Please try again later.' }))
} finally {
setRunningMap(prev => ({ ...prev, [language]: false }))
}
}
function removeChip(language: QueryLanguage, label: string) {
setChipsMap(prev => ({
...prev,
[language]: (prev[language] || []).filter(item => item !== label)
}))
}
function toggleMode() {
updateState({ builderMode: state.builderMode === 'visual' ? 'code' : 'visual' })
}
function setInputMode(language: QueryLanguage, mode: QueryInputMode) {
setInputModeMap(prev => ({ ...prev, [language]: mode }))
}
function handleQueryChange(language: QueryLanguage, value: string) {
const dataSource = languageMeta[language].dataSource
setInputMode(language, 'ql')
updateState({
queries: { ...state.queries, [language]: value },
queryLanguage: language,
dataSource,
activeLanguages: Array.from(new Set([...state.activeLanguages, language]))
})
}
function handleHistoryInsert(language: QueryLanguage, query: string) {
handleQueryChange(language, query)
}
function handleCollapse(language: QueryLanguage) {
setCollapsedMap(prev => ({ ...prev, [language]: !prev[language] }))
}
if (activePanels.length === 0) {
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-6 text-sm text-slate-300">
Select a query language from the navigation to start exploring data.
</div>
)
}
const panels = activePanels.map(language => {
const meta = languageMeta[language]
const chips = chipsMap[language] || []
const historyItems = history[language] || []
const isCollapsed = collapsedMap[language]
const inputMode = inputModeMap[language] || 'ql'
return (
<section
key={language}
className="flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20"
>
<header className="panel-drag-handle flex flex-wrap items-start gap-3">
<div>
<h3 className="text-sm font-semibold text-slate-200">{meta.label}</h3>
<p className="text-xs text-slate-400">{meta.description}</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-slate-400">
<div className="flex items-center gap-2">
<span className="hidden sm:inline">Mode:</span>
<div className="flex overflow-hidden rounded-xl border border-slate-800">
<button
type="button"
onClick={() => setInputMode(language, 'ql')}
className={`px-3 py-1 text-xs font-medium transition ${
inputMode === 'ql'
? 'bg-emerald-500/20 text-emerald-200'
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
}`}
>
QL input
</button>
<button
type="button"
onClick={() => setInputMode(language, 'menu')}
className={`px-3 py-1 text-xs font-medium transition ${
inputMode === 'menu'
? 'bg-emerald-500/20 text-emerald-200'
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
}`}
>
Menu select
</button>
</div>
</div>
<button
type="button"
onClick={() => handleCollapse(language)}
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
{inputMode === 'ql' && (
<button
type="button"
onClick={toggleMode}
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
>
{state.builderMode === 'visual' ? 'Switch to code' : 'Switch to visual'}
</button>
)}
</div>
</header>
{!isCollapsed && (
<div className="mt-4 flex flex-1 flex-col gap-4">
{inputMode === 'menu' ? (
<div className="space-y-3 text-xs text-slate-200">
<ContextSummary state={state} />
<div>
<p className="text-[11px] uppercase tracking-wide text-slate-500">Query preview</p>
<pre className="mt-2 max-h-32 overflow-auto rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-xs text-emerald-200">
{state.queries[language] || meta.placeholder}
</pre>
</div>
</div>
) : state.builderMode === 'visual' ? (
<div className="space-y-3">
<QueryChips labels={chips} onRemove={label => removeChip(language, label)} />
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-xs text-slate-400">
Aggregation
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
<option>sum</option>
<option>avg</option>
<option>max</option>
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-slate-400">
Window
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
<option>5m</option>
<option>15m</option>
<option>1h</option>
</select>
</label>
</div>
<textarea
value={state.queries[language] || ''}
onChange={event => handleQueryChange(language, event.target.value)}
placeholder={meta.placeholder}
className="h-32 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-sm text-slate-200 shadow-inner"
/>
</div>
) : (
<textarea
value={state.queries[language] || ''}
onChange={event => handleQueryChange(language, event.target.value)}
placeholder={meta.placeholder}
className="h-48 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 font-mono text-sm text-slate-200 shadow-inner"
/>
)}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => runQuery(language)}
disabled={runningMap[language]}
className="rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950 shadow-lg disabled:opacity-50"
>
{runningMap[language] ? 'Running…' : 'Run query'}
</button>
<button
onClick={() => {
const query = state.queries[language] || meta.placeholder
const unique = [query, ...historyItems.filter(item => item !== query)].slice(0, 15)
setHistory(language, unique)
}}
className="rounded-xl border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"
>
Save to history
</button>
{messageMap[language] && <span className="text-xs text-slate-400">{messageMap[language]}</span>}
</div>
<QueryHistoryPanel
history={historyItems}
onInsert={query => handleHistoryInsert(language, query)}
onClear={() => setHistory(language, [])}
/>
</div>
)}
</section>
)
})
if (panels.length === 1) {
return panels[0]
}
return <div className="space-y-4">{panels}</div>
}
function ContextSummary({ state }: { state: InsightState }) {
const context = [
{ label: 'Org', value: state.org },
{ label: 'Environment', value: state.env },
{ label: 'Region', value: state.region },
{ label: 'Project', value: state.project },
{ label: 'Time range', value: state.timeRange }
]
return (
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-3">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Global context</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{context.map(item => (
<span
key={item.label}
className="flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/70 px-3 py-1 text-xs text-slate-200"
>
<span className="text-slate-500">{item.label}</span>
<span className="font-medium text-slate-100">{item.value}</span>
</span>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { InsightState } from '../store/urlState'
import { TimeRangePicker } from './TimeRangePicker'
interface BreadcrumbBarProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
shareableLink: string
}
const orgs = ['global-org', 'retail-hub', 'fintech-lab']
const projects = ['observability', 'payments', 'edge']
const envs = ['production', 'staging', 'dev']
const regions = ['us-west-2', 'eu-central-1', 'ap-southeast-1']
export function BreadcrumbBar({ state, updateState, shareableLink }: BreadcrumbBarProps) {
const [copied, setCopied] = useState(false)
const canCopy = Boolean(shareableLink)
async function handleCopy() {
if (!canCopy) {
return
}
try {
await navigator.clipboard.writeText(shareableLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Copy failed', err)
}
}
return (
<div className="flex w-full flex-wrap items-center gap-3 text-sm text-slate-200">
<Selector label="Org" value={state.org} options={orgs} onChange={org => updateState({ org })} />
<Separator />
<Selector label="Environment" value={state.env} options={envs} onChange={env => updateState({ env })} />
<Separator />
<Selector label="Region" value={state.region} options={regions} onChange={region => updateState({ region })} />
<Separator />
<Selector label="Project" value={state.project} options={projects} onChange={project => updateState({ project })} />
<Separator />
<TimeRangePicker state={state} updateState={updateState} />
<button
onClick={handleCopy}
disabled={!canCopy}
className={`ml-auto flex items-center gap-2 rounded-xl border border-slate-700 px-3 py-1.5 text-xs font-medium text-slate-200 transition ${canCopy ? 'hover:bg-slate-800' : 'opacity-60 cursor-not-allowed'
}`}
aria-disabled={!canCopy}
>
{copied ? 'Link copied!' : canCopy ? 'Copy share link' : 'Generating share link...'}
</button>
</div>
)
}
function Selector({
label,
value,
options,
onChange
}: {
label: string
value: string
options: string[]
onChange: (value: string) => void
}) {
return (
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 shadow-inner">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<select
value={value}
onChange={event => onChange(event.target.value)}
className="w-full bg-transparent text-sm font-medium focus:outline-none"
>
{options.map(option => (
<option key={option} value={option} className="bg-slate-900">
{option}
</option>
))}
</select>
</label>
)
}
function Separator() {
return <span className="text-slate-600">/</span>
}

View File

@ -0,0 +1,243 @@
'use client'
import React from 'react'
import { BellRing, Compass, Layers, Sparkles, LayoutDashboard, type LucideIcon, PanelLeftClose, PanelLeftOpen, EyeOff } from 'lucide-react'
import { QueryLanguage, TopologyMode } from '../store/urlState'
import { SidebarHeader, SidebarContent } from '@/components/layout/SidebarRoot'
interface InsightSidebarContentProps {
topologyMode: TopologyMode
activeLanguages: QueryLanguage[]
onSelectSection: (section: string) => void
onTopologyChange: (mode: TopologyMode) => void
onToggleLanguage: (language: QueryLanguage) => void
onToggleCollapse: () => void
onHide: () => void
activeSection: string
collapsed: boolean
}
const sections: { id: string; label: string; icon: LucideIcon; href?: string }[] = [
{ id: 'topology', label: 'Topology', icon: Layers },
{ id: 'explore', label: 'Explore', icon: Compass },
{ id: 'grafana', label: 'Dashboards', icon: LayoutDashboard, href: '/grafana' },
{ id: 'slo', label: 'SLO & Alerts', icon: BellRing },
{ id: 'ai', label: 'AI Assistant', icon: Sparkles }
]
const topologyOptions: { id: TopologyMode; label: string; hint: string }[] = [
{ id: 'application', label: 'Application', hint: 'Services and dependencies' },
{ id: 'network', label: 'Network', hint: 'Gateways, meshes and edges' },
{ id: 'resource', label: 'Resource', hint: 'Clusters, nodes and workloads' }
]
const languageOptions: { id: QueryLanguage; label: string; description: string }[] = [
{ id: 'promql', label: 'PromQL', description: 'Metrics analytics' },
{ id: 'logql', label: 'LogQL', description: 'Log navigation' },
{ id: 'traceql', label: 'TraceQL', description: 'Trace exploration' }
]
const languageLabels: Record<QueryLanguage, string> = {
promql: 'Prometheus metrics',
logql: 'Log stream',
traceql: 'Distributed traces'
}
export function InsightSidebarContent({
topologyMode,
activeLanguages,
activeSection,
onSelectSection,
onTopologyChange,
onToggleLanguage,
onToggleCollapse,
onHide,
collapsed
}: InsightSidebarContentProps) {
return (
<>
<SidebarHeader className={`flex items-start justify-between mb-7 ${collapsed ? 'flex-col items-center gap-4' : ''}`}>
{!collapsed && (
<div className="space-y-2">
<h1 className="text-lg font-semibold text-slate-100">Observability Workbench</h1>
<p className="text-sm text-slate-400">
Navigate topology, run cross-domain queries and keep SLOs on track.
</p>
</div>
)}
<div className={`flex items-center gap-2 ${collapsed ? '' : 'ml-2'}`}>
<button
type="button"
onClick={onToggleCollapse}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
<button
type="button"
onClick={onHide}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-red-500/60 hover:text-red-300"
aria-label="Hide sidebar"
>
<EyeOff className="h-4 w-4" />
</button>
</div>
</SidebarHeader>
<SidebarContent className={`space-y-7 ${collapsed ? 'flex flex-col items-center gap-2' : ''}`}>
<nav className={`space-y-1 ${collapsed ? 'w-full flex flex-col items-center gap-2 space-y-0' : ''}`}>
{sections.map(section => {
const active = activeSection === section.id
const Icon = section.icon
if (section.href) {
return (
<a
key={section.id}
href={section.href}
className={`w-full rounded-xl transition ${collapsed
? 'flex flex-col items-center gap-2 px-2 py-3'
: 'flex items-center gap-3 px-3 py-2 text-left'
} ${active
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
: 'text-slate-300 hover:bg-slate-800/60'
}`}
title={section.label}
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
}`}
>
<Icon className="h-5 w-5" />
</span>
{!collapsed && <span className="font-medium">{section.label}</span>}
</a>
)
}
return (
<div key={section.id} className={`group relative ${collapsed ? 'w-full' : ''}`}>
<button
onClick={() => onSelectSection(section.id)}
className={`w-full rounded-xl transition ${collapsed
? 'flex flex-col items-center gap-2 px-2 py-3'
: 'flex items-center gap-3 px-3 py-2 text-left'
} ${active
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
: 'text-slate-300 hover:bg-slate-800/60'
}`}
title={section.label}
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
}`}
>
<Icon className="h-5 w-5" />
</span>
{!collapsed && <span className="font-medium">{section.label}</span>}
</button>
{section.id === 'topology' && (
<div
className={`pointer-events-none absolute z-20 hidden w-60 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Topology mode</p>
<p className="text-xs text-slate-500">Hover to select how the topology map is rendered.</p>
</div>
<div className="flex flex-col gap-2">
{topologyOptions.map(option => {
const activeMode = topologyMode === option.id
return (
<button
key={option.id}
className={`flex flex-col rounded-xl border px-3 py-2 text-left transition ${activeMode
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onTopologyChange(option.id)}
type="button"
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-xs text-slate-400">{option.hint}</span>
</button>
)
})}
</div>
</div>
</div>
)}
{section.id === 'explore' && (
<div
className={`pointer-events-none absolute z-20 hidden w-64 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Query domains</p>
<p className="text-xs text-slate-500">Toggle languages to open matching explorers.</p>
</div>
<div className="flex flex-col gap-2">
{languageOptions.map(option => {
const activeLanguage = activeLanguages.includes(option.id)
return (
<button
key={option.id}
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition ${activeLanguage
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onToggleLanguage(option.id)}
type="button"
>
<span className="font-medium">{option.label}</span>
<span className="text-xs text-slate-400">{option.description}</span>
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
})}
</nav>
<div
className={`rounded-2xl border border-slate-800 bg-gradient-to-br from-slate-800/80 to-slate-900 shadow-inner ${collapsed ? 'p-3' : 'p-4'
}`}
>
{!collapsed ? (
<>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Active explorers</p>
<ul className="space-y-1 text-sm text-slate-300">
{activeLanguages.map(language => (
<li key={language} className="flex items-center justify-between">
<span>{languageLabels[language]}</span>
<span className="text-xs text-slate-500">QL</span>
</li>
))}
{activeLanguages.length === 0 && <li className="text-xs text-slate-500">No languages selected.</li>}
</ul>
</>
) : (
<div className="flex flex-col items-center gap-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Active</p>
<div className="flex flex-col items-center gap-1 text-[10px] text-slate-300">
{activeLanguages.map(language => (
<span key={language}>{languageLabels[language]}</span>
))}
{activeLanguages.length === 0 && <span className="text-slate-500">None</span>}
</div>
</div>
)}
</div>
</SidebarContent>
</>
)
}

View File

@ -0,0 +1,31 @@
'use client'
import React from 'react'
import { QueryLanguage, TopologyMode } from '../store/urlState'
import { SidebarHeader, SidebarContent } from '@/components/layout/SidebarRoot'
import { InsightSidebarContent } from './InsightSidebarContent'
interface SidebarProps {
topologyMode: TopologyMode
activeLanguages: QueryLanguage[]
onSelectSection: (section: string) => void
onTopologyChange: (mode: TopologyMode) => void
onToggleLanguage: (language: QueryLanguage) => void
onToggleCollapse: () => void
onHide: () => void
activeSection: string
collapsed: boolean
}
export function Sidebar(props: SidebarProps) {
const { collapsed } = props
return (
<SidebarRoot
className={`border-r border-slate-800 bg-slate-900/70 px-3 py-6 backdrop-blur ${collapsed ? 'w-20' : 'w-full lg:w-72 xl:w-80'
}`}
>
<InsightSidebarContent {...props} />
</SidebarRoot>
)
}

View File

@ -0,0 +1,33 @@
'use client'
import { InsightState } from '../../insight/store/urlState'
import { formatDuration } from '@lib/format'
interface TimeRangePickerProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
const ranges = ['15m', '1h', '6h', '24h', '7d']
export function TimeRangePicker({ state, updateState }: TimeRangePickerProps) {
return (
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 text-sm text-slate-200 shadow-inner">
<span className="text-xs uppercase tracking-wide text-slate-500">Time range</span>
<select
value={state.timeRange}
onChange={event => updateState({ timeRange: event.target.value })}
className="w-full bg-transparent text-sm font-medium focus:outline-none"
>
{ranges.map(range => (
<option key={range} value={range} className="bg-slate-900">
{formatDuration(range)}
</option>
))}
<option value="custom" className="bg-slate-900">
Custom window
</option>
</select>
</label>
)
}

View File

@ -0,0 +1,88 @@
'use client'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import dynamic from 'next/dynamic'
import type { Layout, ReactGridLayoutProps } from 'react-grid-layout'
interface WorkspacePanel {
id: string
domId?: string
content: ReactNode
minW?: number
minH?: number
}
interface WorkspaceGridProps {
layout: Layout[]
defaultLayout: Layout[]
panels: WorkspacePanel[]
onLayoutChange: (layout: Layout[]) => void
draggableHandle?: string
}
const ReactGridLayout = dynamic(
() =>
import('react-grid-layout').then(mod => {
const baseComponent = mod.default
const widthProvider = (mod as any).WidthProvider ?? (mod.default as any)?.WidthProvider
if (!widthProvider || !baseComponent) {
throw new Error('Unable to load react-grid-layout WidthProvider')
}
const WrappedGrid = (widthProvider as any)(baseComponent as any)
const Adapter: React.ComponentType<ReactGridLayoutProps> = props => <WrappedGrid {...props} />
return Adapter as React.ComponentType<ReactGridLayoutProps>
}),
{ ssr: false }
) as React.ComponentType<ReactGridLayoutProps>
export function WorkspaceGrid({
layout,
defaultLayout,
panels,
onLayoutChange,
draggableHandle
}: WorkspaceGridProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const mergedLayout = useMemo(() => {
const defaultsById = Object.fromEntries(defaultLayout.map(item => [item.i, item]))
return layout.map(item => ({
...defaultsById[item.i],
...item
}))
}, [defaultLayout, layout])
if (!mounted) {
return <div className="grid gap-4 md:grid-cols-2"><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /></div>
}
return (
<div className="insight-grid">
<ReactGridLayout
layout={mergedLayout}
cols={12}
rowHeight={36}
margin={[16, 16]}
containerPadding={[0, 0]}
draggableHandle={draggableHandle}
onLayoutChange={onLayoutChange}
compactType={null}
isBounded
useCSSTransforms
>
{panels.map(panel => (
<div key={panel.id} id={panel.domId}>
<div className="h-full">{panel.content}</div>
</div>
))}
</ReactGridLayout>
</div>
)
}

View File

@ -0,0 +1,44 @@
'use client'
import { InsightState } from '../store/urlState'
import { BreadcrumbBar } from './BreadcrumbBar'
interface WorkspaceHeaderProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
shareableLink: string
statusMessage?: string | null
showBreadcrumb?: boolean
}
export function WorkspaceHeader({
state,
updateState,
shareableLink,
statusMessage,
showBreadcrumb = true
}: WorkspaceHeaderProps) {
return (
<header className="rounded-2xl border border-slate-800 bg-slate-900/70 px-6 py-5 shadow-lg shadow-slate-950/20">
<div className="space-y-2">
{showBreadcrumb && (
<BreadcrumbBar state={state} updateState={updateState} shareableLink={shareableLink} />
)}
<p className="text-xs text-slate-400">
Drag any panel handle to reorganize the workspace. Saved layouts stay local to your browser.
</p>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
<span>
Shareable link:{' '}
<span className="text-slate-200">{shareableLink || 'State syncs in the URL hash for collaboration.'}</span>
</span>
{statusMessage ? (
<span className="text-emerald-300">{statusMessage}</span>
) : (
<span>Changes you save apply only to this device.</span>
)}
</div>
</header>
)
}

View File

@ -0,0 +1,38 @@
import { createOpenObserveClient } from './openobserve'
export interface LogEntry {
timestamp: number
message: string
level: string
service: string
fields?: Record<string, any>
}
const mockLogs: LogEntry[] = Array.from({ length: 25 }).map((_, idx) => ({
timestamp: Date.now() - idx * 15000,
message: `Request handled with status ${idx % 5 === 0 ? 500 : 200}`,
level: idx % 5 === 0 ? 'error' : idx % 3 === 0 ? 'warn' : 'info',
service: idx % 2 === 0 ? 'checkout' : 'payments',
fields: {
traceId: `trace-${idx}`,
spanId: `span-${idx}`
}
}))
export async function fetchLogs(query: string) {
const adapter = createLogsAdapter()
return adapter.queryLogs(query)
}
export function createLogsAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryLogs(query: string, params?: Record<string, string>) {
void params
return await client.request<LogEntry[]>(`/logs/query`, {
method: 'POST',
body: JSON.stringify({ query })
})
}
}
}

View File

@ -0,0 +1,24 @@
export interface ClientOptions {
baseUrl?: string
token?: string
}
export function createOpenObserveClient(options: ClientOptions = {}) {
const { baseUrl = 'https://infra.svc.plus/api', token } = options
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers
})
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json() as Promise<T>
}
return { request }
}

View File

@ -0,0 +1,49 @@
import { createOpenObserveClient } from './openobserve'
interface SeriesPoint {
timestamp: number
value: number
}
export interface PrometheusResponse {
metric: string
points: SeriesPoint[]
}
const mockSeries: PrometheusResponse[] = [
{
metric: 'latency_p95',
points: Array.from({ length: 20 }).map((_, idx) => ({
timestamp: Date.now() - (19 - idx) * 60000,
value: 120 + Math.sin(idx / 2) * 30
}))
},
{
metric: 'error_rate',
points: Array.from({ length: 20 }).map((_, idx) => ({
timestamp: Date.now() - (19 - idx) * 60000,
value: 0.5 + Math.cos(idx / 1.5) * 0.1
}))
}
]
export async function fetchPromQL(query: string) {
const adapter = createPrometheusAdapter()
return adapter.queryRange(query)
}
export function createPrometheusAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryRange(query: string, params?: Record<string, string>) {
void params
return await client.request<PrometheusResponse[]>(`/prometheus/api/v1/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ query }).toString()
})
}
}
}

View File

@ -0,0 +1,62 @@
import { createOpenObserveClient } from './openobserve'
export interface TraceSpan {
id: string
parentId?: string
name: string
service: string
durationMs: number
startTime: number
}
const mockTrace: TraceSpan[] = [
{
id: 'root',
name: 'GET /checkout',
service: 'gateway',
durationMs: 250,
startTime: Date.now() - 250
},
{
id: 'auth',
parentId: 'root',
name: 'AuthService.verify',
service: 'auth',
durationMs: 45,
startTime: Date.now() - 240
},
{
id: 'payments',
parentId: 'root',
name: 'PaymentService.charge',
service: 'payments',
durationMs: 110,
startTime: Date.now() - 200
},
{
id: 'db',
parentId: 'payments',
name: 'DB.query',
service: 'postgres',
durationMs: 80,
startTime: Date.now() - 180
}
]
export async function fetchTraces(query: string) {
const adapter = createTracesAdapter()
return adapter.queryTraces(query)
}
export function createTracesAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryTraces(query: string, params?: Record<string, string>) {
void params
return await client.request<TraceSpan[]>(`/traces/query`, {
method: 'POST',
body: JSON.stringify({ query })
})
}
}
}

View File

@ -0,0 +1,19 @@
import { DataSource } from '../store/urlState'
export function buildCorrelatedQuery(source: DataSource, context: {
service: string
namespace: string
timeRange: string
}) {
const baseLabels = `service="${context.service}"${context.namespace ? `,namespace="${context.namespace}"` : ''}`
switch (source) {
case 'metrics':
return `sum(rate(http_requests_total{${baseLabels}}[5m]))`
case 'logs':
return `{${baseLabels}} | json`
case 'traces':
return `traces{${baseLabels}} | duration > 50ms`
default:
return ''
}
}

View File

@ -0,0 +1,28 @@
export interface AlertConfig {
name: string
description: string
condition: string
severity: 'info' | 'warning' | 'critical'
silenceMinutes?: number
receivers: string[]
}
export interface GitOpsPreview {
branch: string
filePath: string
diff: string
}
export async function createAlertPR(config: AlertConfig): Promise<GitOpsPreview> {
const diff = `--- a/alerts/${config.name}.yaml\n+++ b/alerts/${config.name}.yaml\n` +
`+name: ${config.name}\n` +
`+description: ${config.description}\n` +
`+condition: ${config.condition}\n` +
`+severity: ${config.severity}\n`
await new Promise(resolve => setTimeout(resolve, 400))
return {
branch: `alerts/${config.name}`,
filePath: `alerts/${config.name}.yaml`,
diff
}
}

View File

@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
import { AlertConfig, createAlertPR } from '../../insight/services/gitops'
interface AlertWizardProps {
template: {
name: string
description: string
condition: string
} | null
onClose: () => void
}
export function AlertWizard({ template, onClose }: AlertWizardProps) {
const [config, setConfig] = useState<AlertConfig>({
name: template?.name ?? 'custom-alert',
description: template?.description ?? '',
condition: template?.condition ?? '',
severity: 'warning',
receivers: [],
silenceMinutes: 0
})
const [preview, setPreview] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
if (!template) return null
async function handleGenerate() {
setIsSubmitting(true)
try {
const result = await createAlertPR(config)
setPreview(result.diff)
} finally {
setIsSubmitting(false)
}
}
function update<K extends keyof AlertConfig>(key: K, value: AlertConfig[K]) {
setConfig(prev => ({ ...prev, [key]: value }))
}
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/80 p-5 shadow-inner">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-slate-100">Alert wizard</h4>
<button onClick={onClose} className="text-xs text-slate-400 hover:text-slate-200">
Close
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2 text-xs text-slate-300">
<label className="flex flex-col gap-1">
Alert name
<input
value={config.name}
onChange={event => update('name', event.target.value)}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Severity
<select
value={config.severity}
onChange={event => update('severity', event.target.value as AlertConfig['severity'])}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</label>
<label className="sm:col-span-2 flex flex-col gap-1">
Condition
<input
value={config.condition}
onChange={event => update('condition', event.target.value)}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Silence (minutes)
<input
type="number"
value={config.silenceMinutes ?? 0}
onChange={event => update('silenceMinutes', Number(event.target.value))}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Receivers (comma separated)
<input
value={config.receivers.join(', ')}
onChange={event => update('receivers', event.target.value.split(',').map(item => item.trim()).filter(Boolean))}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="sm:col-span-2 flex flex-col gap-1">
Description
<textarea
value={config.description}
onChange={event => update('description', event.target.value)}
className="h-24 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
</div>
<button
onClick={handleGenerate}
disabled={isSubmitting}
className="rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950 disabled:opacity-50"
>
{isSubmitting ? 'Generating…' : 'Generate GitOps PR'}
</button>
{preview && (
<pre className="max-h-48 overflow-auto rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-[12px] text-slate-300">
{preview}
</pre>
)}
</div>
)
}

View File

@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { InsightState, TopologyMode } from '../../insight/store/urlState'
import { AlertWizard } from './AlertWizard'
interface Template {
name: string
title: string
description: string
condition: string
hint: string
}
const templateLibrary: Record<TopologyMode, Template[]> = {
application: [
{
name: 'app-availability',
title: 'Availability ≥ 99.9%',
description: 'Golden signal uptime goal across the selected service mesh.',
condition: 'avg_over_time(up{namespace="default"}[30d]) < 0.999',
hint: 'Map to your core services, adjust window and rollup.'
},
{
name: 'app-latency',
title: 'P95 latency < 300ms',
description: 'Track tail latency for customer-facing endpoints.',
condition: 'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m]))) > 0.3',
hint: 'Swap service label and percentile to match expectations.'
},
{
name: 'app-error',
title: 'Error rate < 1%',
description: 'Alert when errors exceed the burn rate for this application.',
condition:
'sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01',
hint: 'Tune thresholds per service-level objective.'
}
],
network: [
{
name: 'net-availability',
title: 'L4 success rate ≥ 99.95%',
description: 'Monitor gateway and load-balancer success ratio.',
condition: 'avg_over_time(connection_success_total[30d]) < 0.9995',
hint: 'Hook into your L4 metrics exporter or eBPF pipeline.'
},
{
name: 'net-latency',
title: 'Network latency < 80ms',
description: 'Watch round-trip time between edge and cluster ingress.',
condition: 'histogram_quantile(0.95, sum(rate(network_rtt_bucket[5m]))) > 0.08',
hint: 'Adjust quantiles per region or provider SLA.'
},
{
name: 'net-packets',
title: 'Packet drop < 0.1%',
description: 'Alert when drops spike on critical gateways.',
condition: 'sum(rate(packet_drop_total[5m])) / sum(rate(packet_total[5m])) > 0.001',
hint: 'Swap metrics for your own networking stack.'
}
],
resource: [
{
name: 'res-cpu',
title: 'CPU saturation < 75%',
description: 'Keep node CPU usage below target across the fleet.',
condition: 'avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) < 0.25',
hint: 'Track per node pool or namespace as needed.'
},
{
name: 'res-memory',
title: 'Memory headroom > 30%',
description: 'Alert when memory usage trends toward exhaustion.',
condition: 'avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.3',
hint: 'Swap metrics for container-level if required.'
},
{
name: 'res-storage',
title: 'Storage IO latency < 20ms',
description: 'Keep disk IO latency within SLO budgets.',
condition: 'avg(rate(node_disk_io_time_seconds_total[5m])) > 0.02',
hint: 'Extend with per-volume thresholds or burst alerts.'
}
]
}
interface SLOPanelProps {
state: InsightState
}
export function SLOPanel({ state }: SLOPanelProps) {
const templates = templateLibrary[state.topologyMode]
const [selected, setSelected] = useState<Template | null>(null)
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div>
<h3 className="text-sm font-semibold text-slate-100">SLO templates</h3>
<p className="text-xs text-slate-400">
Golden signals curated for {modeTitles[state.topologyMode]}. ({state.env} / {state.region})
</p>
<p className="text-[11px] text-slate-500">Extend or replace each template with your own objectives.</p>
</div>
<div className="space-y-3">
{templates.map(template => (
<button
key={template.name}
onClick={() => setSelected(template)}
className="w-full rounded-2xl border border-slate-800 bg-slate-950/60 px-4 py-3 text-left transition hover:border-emerald-500/60"
>
<p className="text-sm font-medium text-slate-200">{template.title}</p>
<p className="text-xs text-slate-400">{template.description}</p>
<p className="text-[11px] text-slate-500">{template.hint}</p>
</button>
))}
</div>
{selected && <AlertWizard template={selected} onClose={() => setSelected(null)} />}
</div>
)
}
const modeTitles: Record<TopologyMode, string> = {
application: 'application monitoring',
network: 'L4 network health',
resource: 'infrastructure resources'
}

View File

@ -0,0 +1,96 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
import { canAccessSnippet } from '@lib/rbac'
interface Snippet {
name: string
domain: 'metrics' | 'logs' | 'traces'
query: string
tags: string[]
rbac?: {
roles?: string[]
environments?: string[]
}
}
interface SnippetLibraryProps {
state: InsightState
onInsert: (query: string, domain: Snippet['domain']) => void
}
const snippets: Snippet[] = [
{
name: 'Checkout error budget',
domain: 'metrics',
query: 'sum(rate(http_requests_total{service="checkout",code=~"5.."}[5m]))',
tags: ['latency', 'error'],
rbac: { environments: ['production', 'staging'] }
},
{
name: 'Payments slow requests',
domain: 'logs',
query: '{service="payments"} |= "duration" |= "slow"',
tags: ['payments', 'latency']
},
{
name: 'Trace checkout flow',
domain: 'traces',
query: 'traces{service="checkout"} | duration > 100ms',
tags: ['trace', 'checkout'],
rbac: { roles: ['sre', 'platform'] }
}
]
export function SnippetLibrary({ state, onInsert }: SnippetLibraryProps) {
const [filter, setFilter] = useState('')
const accessibleSnippets = useMemo(() => {
const normalized = filter.trim().toLowerCase()
return snippets.filter(snippet => {
if (!canAccessSnippet(snippet.rbac, { role: 'sre', env: state.env })) return false
if (!normalized) return true
return (
snippet.name.toLowerCase().includes(normalized) ||
snippet.query.toLowerCase().includes(normalized) ||
snippet.tags.some(tag => tag.toLowerCase().includes(normalized))
)
})
}, [filter, state.env])
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-100">Team snippets</h3>
<input
value={filter}
onChange={event => setFilter(event.target.value)}
placeholder="Search tags"
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-1.5 text-xs text-slate-200"
/>
</div>
<div className="space-y-3">
{accessibleSnippets.length === 0 ? (
<p className="text-xs text-slate-500">No snippets found for the current filters.</p>
) : (
accessibleSnippets.map(snippet => (
<div key={snippet.name} className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<div className="flex items-center justify-between text-sm text-slate-200">
<span className="font-medium">{snippet.name}</span>
<span className="text-xs uppercase tracking-wide text-slate-500">{snippet.domain}</span>
</div>
<p className="mt-1 text-xs text-slate-400">Tags: {snippet.tags.join(', ')}</p>
<pre className="mt-2 overflow-x-auto rounded-xl bg-slate-900/70 p-3 text-[12px] text-slate-300">{snippet.query}</pre>
<button
onClick={() => onInsert(snippet.query, snippet.domain)}
className="mt-3 rounded-xl border border-slate-700 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800"
>
Insert into builder
</button>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,209 @@
export type TopologyMode = 'application' | 'network' | 'resource'
export type DataSource = 'metrics' | 'logs' | 'traces'
export type BuilderMode = 'visual' | 'code'
export type QueryLanguage = 'promql' | 'logql' | 'traceql'
export type QueryInputMode = 'ql' | 'menu'
export interface InsightState {
org: string
project: string
env: string
region: string
topologyMode: TopologyMode
namespace: string
service: string
dataSource: DataSource
queryLanguage: QueryLanguage
queries: Record<QueryLanguage, string>
activeLanguages: QueryLanguage[]
builderMode: BuilderMode
timeRange: string
}
export const DEFAULT_INSIGHT_STATE: InsightState = {
org: 'global-org',
project: 'observability',
env: 'production',
region: 'us-west-2',
topologyMode: 'application',
namespace: 'default',
service: '',
dataSource: 'metrics',
queryLanguage: 'promql',
queries: {
promql: 'sum(rate(http_requests_total{job="api"}[5m]))',
logql: '{service="checkout"} |= "error"',
traceql: 'traces{service="checkout"} | duration > 250ms'
},
activeLanguages: ['promql'],
builderMode: 'visual',
timeRange: '1h'
}
const STATE_KEY_MAP: Record<keyof InsightState, string> = {
org: 'org',
project: 'proj',
env: 'env',
region: 'reg',
topologyMode: 'mode',
namespace: 'ns',
service: 'svc',
dataSource: 'ds',
queryLanguage: 'ql',
queries: 'qs',
activeLanguages: 'qls',
builderMode: 'bm',
timeRange: 'tr'
}
const REVERSE_STATE_KEY_MAP = Object.fromEntries(
Object.entries(STATE_KEY_MAP).map(([key, value]) => [value, key])
) as Record<string, keyof InsightState>
export function serializeInsightState(state: InsightState): string {
const params = new URLSearchParams()
;(Object.keys(STATE_KEY_MAP) as (keyof InsightState)[]).forEach(key => {
const value = state[key]
if (value === undefined || value === null) return
switch (key) {
case 'queries': {
const queries = state.queries
if (!queries) return
const customQueries: Partial<Record<QueryLanguage, string>> = {}
;(Object.keys(queries) as QueryLanguage[]).forEach(language => {
if (queries[language] !== DEFAULT_INSIGHT_STATE.queries[language]) {
customQueries[language] = queries[language]
}
})
if (Object.keys(customQueries).length === 0) return
params.set(
STATE_KEY_MAP[key],
encodeURIComponent(JSON.stringify(customQueries))
)
break
}
case 'activeLanguages': {
const activeLanguages = Array.isArray(value)
? value
: [value as QueryLanguage]
const defaultActive = DEFAULT_INSIGHT_STATE.activeLanguages
if (
activeLanguages.length === defaultActive.length &&
activeLanguages.every((language, index) => language === defaultActive[index])
) {
return
}
params.set(STATE_KEY_MAP[key], activeLanguages.join(','))
break
}
default:
if (value === DEFAULT_INSIGHT_STATE[key]) return
params.set(STATE_KEY_MAP[key], String(value))
}
})
return params.toString()
}
export function deserializeInsightState(hash: string): InsightState {
if (!hash) return DEFAULT_INSIGHT_STATE
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(cleanHash)
const next: InsightState = { ...DEFAULT_INSIGHT_STATE }
params.forEach((value, key) => {
const stateKey = REVERSE_STATE_KEY_MAP[key]
if (!stateKey) return
switch (stateKey) {
case 'topologyMode':
next.topologyMode = value as TopologyMode
break
case 'dataSource':
next.dataSource = value as DataSource
break
case 'queryLanguage':
next.queryLanguage = value as QueryLanguage
break
case 'builderMode':
next.builderMode = value as BuilderMode
break
case 'org':
next.org = value
break
case 'project':
next.project = value
break
case 'env':
next.env = value
break
case 'region':
next.region = value
break
case 'namespace':
next.namespace = value
break
case 'service':
next.service = value
break
case 'queries':
try {
const decoded = decodeURIComponent(value)
const parsed = JSON.parse(decoded)
next.queries = {
promql: parsed.promql || DEFAULT_INSIGHT_STATE.queries.promql,
logql: parsed.logql || DEFAULT_INSIGHT_STATE.queries.logql,
traceql: parsed.traceql || DEFAULT_INSIGHT_STATE.queries.traceql
}
} catch (error) {
console.error('Failed to parse queries from URL state', error)
next.queries = { ...DEFAULT_INSIGHT_STATE.queries }
}
break
case 'activeLanguages':
next.activeLanguages = value
.split(',')
.map(item => item.trim())
.filter(Boolean) as QueryLanguage[]
if (next.activeLanguages.length === 0) {
next.activeLanguages = [...DEFAULT_INSIGHT_STATE.activeLanguages]
}
break
case 'timeRange':
next.timeRange = value
break
default:
break
}
})
if (!['application', 'network', 'resource'].includes(next.topologyMode)) {
next.topologyMode = DEFAULT_INSIGHT_STATE.topologyMode
}
if (!['metrics', 'logs', 'traces'].includes(next.dataSource)) {
next.dataSource = DEFAULT_INSIGHT_STATE.dataSource
}
if (!['promql', 'logql', 'traceql'].includes(next.queryLanguage)) {
next.queryLanguage = DEFAULT_INSIGHT_STATE.queryLanguage
}
if (!['visual', 'code'].includes(next.builderMode)) {
next.builderMode = DEFAULT_INSIGHT_STATE.builderMode
}
next.activeLanguages = next.activeLanguages.filter(language =>
['promql', 'logql', 'traceql'].includes(language)
) as QueryLanguage[]
if (next.activeLanguages.length === 0) {
next.activeLanguages = [...DEFAULT_INSIGHT_STATE.activeLanguages]
}
if (!next.queries) {
next.queries = { ...DEFAULT_INSIGHT_STATE.queries }
} else {
next.queries = {
promql: next.queries.promql || DEFAULT_INSIGHT_STATE.queries.promql,
logql: next.queries.logql || DEFAULT_INSIGHT_STATE.queries.logql,
traceql: next.queries.traceql || DEFAULT_INSIGHT_STATE.queries.traceql
}
}
return next
}

View File

@ -0,0 +1,154 @@
'use client'
import { create } from 'zustand'
import {
DEFAULT_INSIGHT_STATE,
InsightState,
serializeInsightState,
deserializeInsightState
} from './urlState'
function getSegments(pathname: string): string[] {
return pathname
.split('/')
.map(segment => segment.trim())
.filter(Boolean)
}
function getBasePath(pathname: string): string {
const segments = getSegments(pathname)
const insightIndex = segments.indexOf('insight')
if (insightIndex === -1) {
return pathname || '/insight'
}
const relevant = segments.slice(0, insightIndex + 1)
return `/${relevant.join('/')}`
}
function getShareIdFromSearch(search: string): string {
if (!search) return ''
const params = new URLSearchParams(search)
return params.get('share') ?? ''
}
function encodeStateId(value: string): string {
if (!value) return ''
const base64 =
typeof window !== 'undefined' && typeof window.btoa === 'function'
? window.btoa(value)
: Buffer.from(value, 'utf-8').toString('base64')
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function decodeStateId(value: string): string | null {
if (!value) return null
try {
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
const padLength = (4 - (padded.length % 4)) % 4
const base64 = padded + '='.repeat(padLength)
return typeof window !== 'undefined' && typeof window.atob === 'function'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('utf-8')
} catch (error) {
console.error('Failed to decode insight share identifier', error)
return null
}
}
function resolveBaseUrl() {
if (typeof window !== 'undefined') {
const { origin, pathname } = window.location
const basePath = getBasePath(pathname)
return `${origin}${basePath}`
}
const configured = process.env.NEXT_PUBLIC_SITE_URL
if (configured) {
const normalized = configured.endsWith('/') ? configured.slice(0, -1) : configured
return normalized.endsWith('/insight') ? normalized : `${normalized}/insight`
}
return ''
}
type InsightStore = {
state: InsightState
shareableLink: string
setInsight: (next: InsightState) => void
updateInsight: (partial: Partial<InsightState>) => void
hydrateFromURL: () => InsightState
syncToURL: () => void
}
export const useInsightStore = create<InsightStore>((set, get) => ({
state: DEFAULT_INSIGHT_STATE,
shareableLink: '',
setInsight: (next) => set({ state: next }),
updateInsight: (partial) =>
set((current) => ({
state: {
...current.state,
...partial,
},
})),
hydrateFromURL: () => {
if (typeof window === 'undefined') {
return get().state
}
const shareId = getShareIdFromSearch(window.location.search)
if (shareId) {
const decoded = decodeStateId(shareId)
if (decoded) {
const hydrated = deserializeInsightState(decoded)
set({ state: hydrated })
return hydrated
}
}
const hydrated = deserializeInsightState(window.location.hash)
set({ state: hydrated })
return hydrated
},
syncToURL: () => {
if (typeof window === 'undefined') {
set({ shareableLink: resolveBaseUrl() })
return
}
const serializedState = serializeInsightState(get().state)
const encoded = encodeStateId(serializedState)
const currentUrl = new URL(window.location.href)
const basePath = getBasePath(currentUrl.pathname)
if (currentUrl.pathname !== basePath) {
currentUrl.pathname = basePath
}
if (encoded) {
currentUrl.searchParams.set('share', encoded)
} else {
currentUrl.searchParams.delete('share')
}
if (!currentUrl.searchParams.toString()) {
currentUrl.search = ''
}
if (currentUrl.hash) {
currentUrl.hash = ''
}
const nextUrl = currentUrl.toString()
if (nextUrl !== window.location.href) {
window.history.replaceState({}, '', nextUrl)
}
const baseUrl = resolveBaseUrl()
set({ shareableLink: encoded ? `${baseUrl}?share=${encoded}` : baseUrl })
},
}))
if (typeof window !== 'undefined') {
useInsightStore.getState().hydrateFromURL()
useInsightStore.subscribe(
(storeState, prevState) => {
if (!prevState || storeState.state === prevState.state) return
useInsightStore.getState().syncToURL()
},
)
useInsightStore.getState().syncToURL()
}

View File

@ -0,0 +1,92 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
import { TopologyCanvas } from './TopologyCanvas'
interface NetworkTopologyPanelProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
export function NetworkTopologyPanel({ state, updateState }: NetworkTopologyPanelProps) {
const [advancedVisible, setAdvancedVisible] = useState(false)
const subtitle = useMemo(
() => `${state.env} · ${state.region} · ${state.org}/${state.project}`,
[state.env, state.org, state.project, state.region]
)
return (
<section
id="topology"
className="flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20"
>
<header className="panel-drag-handle flex flex-wrap items-start gap-3">
<div className="space-y-1">
<h2 className="text-base font-semibold text-slate-200">Network topology</h2>
<p className="text-xs text-slate-400">{subtitle}</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-slate-300">
<button
type="button"
onClick={() => setAdvancedVisible(prev => !prev)}
className="rounded-xl border border-slate-800 px-3 py-1 transition hover:border-slate-700 hover:text-slate-100"
>
{advancedVisible ? 'Hide advanced filters' : 'Show advanced filters'}
</button>
<button
type="button"
onClick={() => updateState({ topologyMode: 'network' })}
className="rounded-xl border border-emerald-600/50 bg-emerald-500/10 px-3 py-1 font-medium text-emerald-200 shadow-inner shadow-emerald-500/20 transition hover:border-emerald-400/70"
>
Focus network
</button>
</div>
</header>
{advancedVisible && (
<div className="mt-4 flex flex-wrap gap-3 text-xs text-slate-300">
<TextField
label="Namespace"
value={state.namespace}
placeholder="default"
onChange={value => updateState({ namespace: value })}
/>
<TextField
label="Service"
value={state.service}
placeholder="all services"
onChange={value => updateState({ service: value })}
/>
</div>
)}
<div className="mt-5 flex-1 overflow-hidden">
<TopologyCanvas state={state} updateState={updateState} />
</div>
</section>
)
}
interface TextFieldProps {
label: string
value: string
placeholder: string
onChange: (value: string) => void
}
function TextField({ label, value, placeholder, onChange }: TextFieldProps) {
return (
<label className="flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
<span className="text-[11px] uppercase tracking-wide text-slate-500">{label}</span>
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="bg-transparent text-sm text-slate-200 focus:outline-none"
/>
</label>
)
}

View File

@ -0,0 +1,189 @@
'use client'
import { MouseEvent, useMemo, useState } from 'react'
import { buildCorrelatedQuery } from '../../insight/services/correlator'
import { InsightState } from '../../insight/store/urlState'
import { TopologyEdge, TopologyNode } from './types'
interface TopologyCanvasProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
interface ContextMenuState {
visible: boolean
x: number
y: number
node?: TopologyNode
}
export function TopologyCanvas({ state, updateState }: TopologyCanvasProps) {
const nodes = useMemo(() => createMockNodes(state.topologyMode), [state.topologyMode])
const edges = useMemo(() => createMockEdges(nodes), [nodes])
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0 })
function handleNodeClick(node: TopologyNode) {
if (node.service) {
updateState({ service: node.service })
}
}
function handleContextMenu(event: MouseEvent, node: TopologyNode) {
event.preventDefault()
setContextMenu({
visible: true,
x: event.clientX,
y: event.clientY,
node
})
}
function handleInspect(target: 'metrics' | 'logs' | 'traces') {
if (!contextMenu.node) return
const query = buildCorrelatedQuery(target, {
service: contextMenu.node.service ?? contextMenu.node.label,
namespace: state.namespace,
timeRange: state.timeRange
})
const language = target === 'metrics' ? 'promql' : target === 'logs' ? 'logql' : 'traceql'
updateState({
dataSource: target,
queryLanguage: language,
queries: { ...state.queries, [language]: query },
activeLanguages: Array.from(new Set([...state.activeLanguages, language])),
service: contextMenu.node.service ?? contextMenu.node.label
})
setContextMenu({ visible: false, x: 0, y: 0 })
}
return (
<div className="relative rounded-2xl border border-slate-800 bg-slate-950/60 p-4 shadow-inner">
<svg viewBox="0 0 600 320" className="h-80 w-full">
{edges.map(edge => {
const from = nodes.find(n => n.id === edge.from)
const to = nodes.find(n => n.id === edge.to)
if (!from || !to) return null
return (
<g key={edge.id}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="#334155"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
<text x={(from.x + to.x) / 2} y={(from.y + to.y) / 2 - 8} className="fill-slate-400 text-[10px]">
{edge.latencyMs.toFixed(0)}ms
</text>
</g>
)
})}
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#334155" />
</marker>
</defs>
{nodes.map(node => (
<g
key={node.id}
transform={`translate(${node.x - 60}, ${node.y - 24})`}
className="cursor-pointer"
onClick={() => handleNodeClick(node)}
onContextMenu={event => handleContextMenu(event, node)}
>
<rect
width={120}
height={48}
rx={18}
className={`stroke-2 ${statusStyles[node.status]} ${
state.service === node.service ? 'stroke-emerald-400' : 'stroke-transparent'
}`}
/>
<rect
width={120}
height={48}
rx={18}
className={`fill-slate-900/90 backdrop-blur ${state.service === node.service ? 'ring-2 ring-emerald-400/60' : ''}`}
/>
<text x={16} y={22} className="fill-slate-200 text-sm font-medium">
{node.label}
</text>
<text x={16} y={36} className="fill-slate-500 text-[11px] uppercase tracking-wide">
{node.type}
</text>
</g>
))}
</svg>
{contextMenu.visible && contextMenu.node && (
<div
className="absolute z-20 rounded-xl border border-slate-700 bg-slate-900/95 px-3 py-2 text-sm text-slate-200 shadow-xl"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-500">Inspect {contextMenu.node.label}</p>
<div className="flex flex-col">
<button onClick={() => handleInspect('metrics')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Metrics
</button>
<button onClick={() => handleInspect('logs')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Logs
</button>
<button onClick={() => handleInspect('traces')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Traces
</button>
</div>
</div>
)}
{contextMenu.visible && (
<div className="absolute inset-0" onClick={() => setContextMenu({ visible: false, x: 0, y: 0 })} />
)}
</div>
)
}
function createMockNodes(mode: InsightState['topologyMode']): TopologyNode[] {
switch (mode) {
case 'network':
return [
{ id: '1', label: 'Edge Router', type: 'network', status: 'healthy', x: 100, y: 140 },
{ id: '2', label: 'Service Mesh', type: 'network', status: 'warning', x: 260, y: 140 },
{ id: '3', label: 'Kubernetes', type: 'network', status: 'healthy', x: 440, y: 140 }
]
case 'resource':
return [
{ id: 'node', label: 'Node pool', type: 'database', status: 'healthy', x: 120, y: 160 },
{ id: 'pod', label: 'Checkout pod', type: 'service', status: 'warning', x: 300, y: 120, service: 'checkout' },
{ id: 'db', label: 'Postgres', type: 'database', status: 'critical', x: 480, y: 180, service: 'postgres' }
]
default:
return [
{ id: 'gw', label: 'API Gateway', type: 'gateway', status: 'healthy', x: 120, y: 120, service: 'gateway' },
{ id: 'checkout', label: 'Checkout', type: 'service', status: 'warning', x: 300, y: 160, service: 'checkout' },
{ id: 'payments', label: 'Payments', type: 'service', status: 'healthy', x: 480, y: 120, service: 'payments' }
]
}
}
function createMockEdges(nodes: TopologyNode[]): TopologyEdge[] {
if (nodes.length < 2) return []
const edges: TopologyEdge[] = []
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({ id: `${nodes[i].id}-${nodes[i + 1].id}`, from: nodes[i].id, to: nodes[i + 1].id, latencyMs: 20 + i * 15 })
}
return edges
}
const statusStyles: Record<TopologyNode['status'], string> = {
healthy: 'stroke-emerald-500/60',
warning: 'stroke-amber-400/60',
critical: 'stroke-red-500/60'
}

View File

@ -0,0 +1,16 @@
export interface TopologyNode {
id: string
label: string
type: 'service' | 'gateway' | 'database' | 'network'
status: 'healthy' | 'warning' | 'critical'
x: number
y: number
service?: string
}
export interface TopologyEdge {
id: string
from: string
to: string
latencyMs: number
}

View File

@ -0,0 +1,49 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
import { getLogLevelColor } from '@lib/format'
interface LogsTableProps {
logs: LogEntry[]
}
export function LogsTable({ logs }: LogsTableProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to inspect log lines in a table.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Time
</th>
<th scope="col" className="px-4 py-2 font-medium">
Level
</th>
<th scope="col" className="px-4 py-2 font-medium">
Service
</th>
<th scope="col" className="px-4 py-2 font-medium">
Message
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{logs.map(log => (
<tr key={`${log.timestamp}-${log.message}`} className="bg-slate-950/50">
<td className="px-4 py-2 text-xs text-slate-400">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className={`px-4 py-2 text-xs font-semibold ${getLogLevelColor(log.level)}`}>{log.level.toUpperCase()}</td>
<td className="px-4 py-2 text-xs text-slate-300">{log.service}</td>
<td className="px-4 py-2 font-mono text-[13px] text-slate-200">{log.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,59 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
interface LogsTopStatsProps {
logs: LogEntry[]
}
export function LogsTopStats({ logs }: LogsTopStatsProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to review top-level log insights.</p>
}
const total = logs.length
const errorCount = logs.filter(log => log.level.toLowerCase() === 'error').length
const errorRate = total ? (errorCount / total) * 100 : 0
const serviceCounts = logs.reduce<Record<string, number>>((acc, log) => {
acc[log.service] = (acc[log.service] ?? 0) + 1
return acc
}, {})
const [topService, topServiceCount] = Object.entries(serviceCounts).sort((a, b) => b[1] - a[1])[0]
const levelCounts = logs.reduce<Record<string, number>>((acc, log) => {
const level = log.level.toUpperCase()
acc[level] = (acc[level] ?? 0) + 1
return acc
}, {})
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Log volume</p>
<p className="mt-1 text-2xl font-semibold text-slate-100">{total}</p>
<p className="text-xs text-slate-500">Entries returned</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Error rate</p>
<p className="mt-1 text-2xl font-semibold text-rose-300">{errorRate.toFixed(1)}%</p>
<p className="text-xs text-slate-500">{errorCount} errors</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Top service</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{topService ?? 'unknown'}</p>
<p className="text-xs text-slate-500">{topServiceCount ?? 0} entries</p>
</div>
<div className="sm:col-span-3 rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Level distribution</p>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(levelCounts).map(([level, count]) => (
<span key={level} className="rounded-full border border-slate-800 px-3 py-1 text-slate-300">
{level}: {count}
</span>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
import { getLogLevelColor } from '@lib/format'
interface LogsViewerProps {
logs: LogEntry[]
}
export function LogsViewer({ logs }: LogsViewerProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to inspect log events.</p>
}
return (
<div className="space-y-2">
{logs.map(log => (
<div
key={`${log.timestamp}-${log.message}`}
className="rounded-xl border border-slate-800 bg-slate-950/60 p-3 text-sm text-slate-200 shadow-inner"
>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className={getLogLevelColor(log.level)}>{log.level.toUpperCase()}</span>
</div>
<p className="mt-2 font-mono text-[13px] text-slate-300">{log.message}</p>
{log.fields && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-slate-900/80 p-2 text-[11px] text-slate-400">
{JSON.stringify(log.fields, null, 2)}
</pre>
)}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,48 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsChartProps {
series: PrometheusResponse[]
}
export function MetricsChart({ series }: MetricsChartProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to render time series data.</p>
}
const width = 520
const height = 200
const flat = series.flatMap(s => s.points)
const min = Math.min(...flat.map(p => p.value))
const max = Math.max(...flat.map(p => p.value))
const range = max - min || 1
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full">
{series.map((s, idx) => {
const path = s.points
.map((point, i) => {
const x = (i / Math.max(1, s.points.length - 1)) * (width - 40) + 20
const y = height - 20 - ((point.value - min) / range) * (height - 40)
return `${i === 0 ? 'M' : 'L'}${x},${y}`
})
.join(' ')
const color = palette[idx % palette.length]
return (
<g key={s.metric}>
<path d={path} fill="none" stroke={color} strokeWidth={2} />
<text x={24} y={20 + idx * 16} className="fill-slate-300 text-[11px]">
{s.metric}: {formatNumber(s.points[s.points.length - 1]?.value ?? 0)}
</text>
</g>
)
})}
<line x1={20} y1={height - 20} x2={width - 20} y2={height - 20} stroke="#1e293b" />
<line x1={20} y1={20} x2={20} y2={height - 20} stroke="#1e293b" />
</svg>
)
}
const palette = ['#34d399', '#60a5fa', '#fbbf24']

View File

@ -0,0 +1,66 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsTableProps {
series: PrometheusResponse[]
}
export function MetricsTable({ series }: MetricsTableProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to inspect series in a tabular view.</p>
}
const rows = series.map(item => {
const values = item.points.map(point => point.value)
const latest = values.at(-1) ?? 0
const min = Math.min(...values)
const max = Math.max(...values)
const avg = values.reduce((acc, value) => acc + value, 0) / (values.length || 1)
return {
metric: item.metric,
latest,
min,
max,
avg
}
})
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Metric
</th>
<th scope="col" className="px-4 py-2 font-medium">
Latest
</th>
<th scope="col" className="px-4 py-2 font-medium">
Average
</th>
<th scope="col" className="px-4 py-2 font-medium">
Min
</th>
<th scope="col" className="px-4 py-2 font-medium">
Max
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{rows.map(row => (
<tr key={row.metric} className="bg-slate-950/50">
<td className="px-4 py-2 font-medium text-slate-100">{row.metric}</td>
<td className="px-4 py-2">{formatNumber(row.latest)}</td>
<td className="px-4 py-2">{formatNumber(row.avg)}</td>
<td className="px-4 py-2">{formatNumber(row.min)}</td>
<td className="px-4 py-2">{formatNumber(row.max)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsTopStatsProps {
series: PrometheusResponse[]
}
export function MetricsTopStats({ series }: MetricsTopStatsProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to surface top metrics and aggregates.</p>
}
const ranked = series
.map(item => {
const values = item.points.map(point => point.value)
const latest = values.at(-1) ?? 0
const peak = Math.max(...values)
return { metric: item.metric, latest, peak }
})
.sort((a, b) => b.latest - a.latest)
.slice(0, 3)
const overallLatest = ranked.reduce((sum, item) => sum + item.latest, 0)
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Combined latest value</p>
<p className="mt-1 text-2xl font-semibold text-emerald-300">{formatNumber(overallLatest)}</p>
</div>
{ranked.map(item => (
<div
key={item.metric}
className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner"
>
<p className="text-xs uppercase tracking-wide text-slate-500">{item.metric}</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{formatNumber(item.latest)}</p>
<p className="text-xs text-slate-500">Peak {formatNumber(item.peak)}</p>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,48 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesTableProps {
spans: TraceSpan[]
}
export function TracesTable({ spans }: TracesTableProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to review spans in a table.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Span
</th>
<th scope="col" className="px-4 py-2 font-medium">
Service
</th>
<th scope="col" className="px-4 py-2 font-medium">
Duration (ms)
</th>
<th scope="col" className="px-4 py-2 font-medium">
Start time
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{spans.map(span => (
<tr key={span.id} className="bg-slate-950/50">
<td className="px-4 py-2 font-medium text-slate-100">{span.name}</td>
<td className="px-4 py-2 text-xs text-slate-300">{span.service}</td>
<td className="px-4 py-2 text-xs text-slate-300">{span.durationMs.toFixed(1)}</td>
<td className="px-4 py-2 text-xs text-slate-400">
{new Date(span.startTime).toLocaleTimeString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,42 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesTopStatsProps {
spans: TraceSpan[]
}
export function TracesTopStats({ spans }: TracesTopStatsProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to surface top spans and bottlenecks.</p>
}
const longestSpan = spans.reduce((prev, current) => (current.durationMs > prev.durationMs ? current : prev), spans[0])
const averageDuration = spans.reduce((sum, span) => sum + span.durationMs, 0) / spans.length
const serviceDurations = spans.reduce<Record<string, number>>((acc, span) => {
acc[span.service] = (acc[span.service] ?? 0) + span.durationMs
return acc
}, {})
const [heaviestService, heaviestDuration] = Object.entries(serviceDurations).sort((a, b) => b[1] - a[1])[0]
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Longest span</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{longestSpan.name}</p>
<p className="text-xs text-slate-500">{longestSpan.durationMs.toFixed(1)} ms</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Average duration</p>
<p className="mt-1 text-2xl font-semibold text-emerald-300">{averageDuration.toFixed(1)} ms</p>
<p className="text-xs text-slate-500">Across {spans.length} spans</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Heaviest service</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{heaviestService ?? 'unknown'}</p>
<p className="text-xs text-slate-500">{(heaviestDuration ?? 0).toFixed(1)} ms total</p>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesWaterfallProps {
spans: TraceSpan[]
}
export function TracesWaterfall({ spans }: TracesWaterfallProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to render waterfall timelines.</p>
}
const rootStart = Math.min(...spans.map(span => span.startTime))
const totalDuration = Math.max(...spans.map(span => span.startTime + span.durationMs)) - rootStart || 1
return (
<div className="space-y-2">
{spans.map(span => {
const offset = ((span.startTime - rootStart) / totalDuration) * 100
const width = (span.durationMs / totalDuration) * 100
return (
<div key={span.id} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-medium text-slate-200">{span.name}</span>
<span>{span.durationMs.toFixed(1)} ms</span>
</div>
<div className="relative h-8 rounded-xl bg-slate-900/80">
<div
className="absolute top-1 h-6 rounded-xl bg-emerald-500/40"
style={{ left: `${offset}%`, width: `${Math.max(width, 4)}%` }}
>
<span className="absolute left-2 top-1 text-[11px] text-emerald-950">{span.service}</span>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { buildCorrelatedQuery } from '../../insight/services/correlator'
import { DataSource, InsightState } from '../../insight/store/urlState'
import { MetricsChart } from './MetricsChart'
import { MetricsTable } from './MetricsTable'
import { MetricsTopStats } from './MetricsTopStats'
import { LogsViewer } from './LogsViewer'
import { LogsTable } from './LogsTable'
import { LogsTopStats } from './LogsTopStats'
import { TracesWaterfall } from './TracesWaterfall'
import { TracesTable } from './TracesTable'
import { TracesTopStats } from './TracesTopStats'
type ViewMode = 'trend' | 'table' | 'top'
interface VizAreaProps {
state: InsightState
data: any
onUpdate: (partial: Partial<InsightState>) => void
}
export function VizArea({ state, data, onUpdate }: VizAreaProps) {
const mode = state.dataSource
const [viewMode, setViewMode] = useState<ViewMode>('trend')
useEffect(() => {
setViewMode('trend')
}, [mode])
const viewOptions = useMemo(
() => [
{ id: 'trend' as ViewMode, label: 'Trend chart' },
{ id: 'table' as ViewMode, label: 'Table' },
{ id: 'top' as ViewMode, label: 'Top stats' }
],
[]
)
const title = modeLabel[mode]
function correlate(target: DataSource) {
const language: InsightState['queryLanguage'] = target === 'metrics' ? 'promql' : target === 'logs' ? 'logql' : 'traceql'
const query = buildCorrelatedQuery(target, {
service: state.service || 'checkout',
namespace: state.namespace,
timeRange: state.timeRange
})
onUpdate({
dataSource: target,
queryLanguage: language,
queries: { ...state.queries, [language]: query },
activeLanguages: Array.from(new Set([...state.activeLanguages, language]))
})
}
function renderContent(): ReactNode {
const metricsSeries = Array.isArray(data) ? data : []
const logs = Array.isArray(data) ? data : []
const traces = Array.isArray(data) ? data : []
switch (mode) {
case 'metrics':
if (viewMode === 'table') {
return <MetricsTable series={metricsSeries} />
}
if (viewMode === 'top') {
return <MetricsTopStats series={metricsSeries} />
}
return <MetricsChart series={metricsSeries} />
case 'logs':
if (viewMode === 'table') {
return <LogsTable logs={logs} />
}
if (viewMode === 'top') {
return <LogsTopStats logs={logs} />
}
return <LogsViewer logs={logs} />
case 'traces':
if (viewMode === 'table') {
return <TracesTable spans={traces} />
}
if (viewMode === 'top') {
return <TracesTopStats spans={traces} />
}
return <TracesWaterfall spans={traces} />
default:
return null
}
}
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div className="flex flex-wrap items-center gap-3">
{title && <h3 className="text-sm font-semibold text-slate-200">{title}</h3>}
<div className={`${title ? 'ml-auto ' : ''}flex flex-wrap items-center gap-2 text-xs text-slate-300`}>
<div className="flex overflow-hidden rounded-xl border border-slate-800">
{viewOptions.map(option => (
<button
key={option.id}
onClick={() => setViewMode(option.id)}
className={`px-3 py-1 text-xs transition ${
viewMode === option.id ? 'bg-slate-800 text-slate-100' : 'bg-slate-900/50 text-slate-400 hover:bg-slate-800'
}`}
>
{option.label}
</button>
))}
</div>
<button onClick={() => correlate('metrics')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Metrics
</button>
<button onClick={() => correlate('logs')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Logs
</button>
<button onClick={() => correlate('traces')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Traces
</button>
<button className="rounded-xl bg-slate-200/10 px-3 py-1 text-slate-200">Save to dashboard</button>
</div>
</div>
<div className="mt-4 min-h-[240px] rounded-2xl border border-slate-800 bg-slate-950/60 p-4 shadow-inner">
{renderContent()}
</div>
</div>
)
}
const modeLabel: Record<DataSource, string> = {
metrics: '',
logs: 'Log stream',
traces: 'Trace waterfall'
}

View File

@ -0,0 +1,47 @@
'use client'
import React from 'react'
import { cn } from '@/lib/utils'
interface SidebarRootProps {
children: React.ReactNode
className?: string
}
/**
* SidebarRoot - The base skeleton for all sidebars.
* Provides the container and common layout behavior.
*/
export function SidebarRoot({ children, className }: SidebarRootProps) {
return (
<aside
className={cn(
"flex h-full flex-col bg-background transition-colors duration-300",
className
)}
>
{children}
</aside>
)
}
/**
* SidebarHeader - Top section of the sidebar (e.g., Branding, Logo).
*/
export function SidebarHeader({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex-shrink-0", className)}>{children}</div>
}
/**
* SidebarContent - Middle scrollable section of the sidebar.
*/
export function SidebarContent({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex-1 overflow-y-auto min-h-0", className)}>{children}</div>
}
/**
* SidebarFooter - Bottom fixed section of the sidebar (e.g., User, Settings, Call to Action).
*/
export function SidebarFooter({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("mt-auto flex-shrink-0", className)}>{children}</div>
}

View File

@ -0,0 +1,87 @@
'use client'
import { useEffect } from 'react'
import { create } from 'zustand'
export type Language = 'en' | 'zh'
type LanguageState = {
language: Language
setLanguage: (lang: Language) => void
hydrateLanguage: () => void
}
const STORAGE_KEY = 'cloudnative-suite.language'
function detectPreferredLanguage(): Language {
if (typeof window === 'undefined') {
return 'zh'
}
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'en' || stored === 'zh') {
return stored
}
const [primaryLocale] = window.navigator.languages?.length
? window.navigator.languages
: [window.navigator.language]
if (typeof primaryLocale === 'string') {
const normalized = primaryLocale.toLowerCase()
if (normalized.startsWith('en')) {
return 'en'
}
if (normalized.startsWith('zh')) {
return 'zh'
}
}
return 'zh'
}
function syncDocumentLanguage(language: Language) {
if (typeof document === 'undefined') {
return
}
document.documentElement.lang = language
document.documentElement.dataset.language = language
}
export const useLanguageStore = create<LanguageState>((set) => ({
language: detectPreferredLanguage(),
setLanguage: (language) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, language)
}
syncDocumentLanguage(language)
set({ language })
},
hydrateLanguage: () => {
const preferred = detectPreferredLanguage()
syncDocumentLanguage(preferred)
set({ language: preferred })
},
}))
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const language = useLanguageStore((state) => state.language)
const hydrateLanguage = useLanguageStore((state) => state.hydrateLanguage)
useEffect(() => {
hydrateLanguage()
}, [hydrateLanguage])
useEffect(() => {
syncDocumentLanguage(language)
}, [language])
return children
}
export function useLanguage() {
const language = useLanguageStore((state) => state.language)
const setLanguage = useLanguageStore((state) => state.setLanguage)
return { language, setLanguage }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type MoltbotLayoutMode = "overlay" | "left-sidebar" | "right-sidebar";
interface MoltbotState {
isOpen: boolean;
isMinimized: boolean;
mode: MoltbotLayoutMode;
width: number;
setIsOpen: (open: boolean) => void;
setMinimized: (minimized: boolean) => void;
setMode: (mode: MoltbotLayoutMode) => void;
toggleOpen: () => void;
close: () => void;
}
export const useMoltbotStore = create<MoltbotState>()(
persist(
(set) => ({
isOpen: false,
isMinimized: false,
mode: "overlay",
width: 400,
setIsOpen: (isOpen) => set({ isOpen }),
setMinimized: (isMinimized) => set({ isMinimized }),
setMode: (mode) => set({ mode }),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
close: () => set({ isOpen: false, isMinimized: false }),
}),
{
name: "moltbot-layout-storage",
}
)
);

View File

@ -0,0 +1,248 @@
'use client'
import { create } from 'zustand'
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
export type TenantMembership = {
id: string
name?: string
role?: UserRole
}
type User = {
id: string
uuid: string
email: string
name?: string
username: string
mfaEnabled: boolean
mfaPending: boolean
role: UserRole
groups: string[]
permissions: string[]
isGuest: boolean
isUser: boolean
isOperator: boolean
isAdmin: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
}
export type SessionUser = User | null
type UserStore = {
user: User | null
isLoading: boolean
setUser: (user: User | null) => void
clearUser: () => void
hydrateFromAPI: () => Promise<User | null>
refresh: () => Promise<User | null>
login: () => Promise<void>
logout: () => Promise<void>
}
const KNOWN_ROLE_MAP: Record<string, UserRole> = {
admin: 'admin',
administrator: 'admin',
operator: 'operator',
ops: 'operator',
user: 'user',
member: 'user',
}
function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') {
return 'guest'
}
const normalized = input.trim().toLowerCase()
if (!normalized) {
return 'guest'
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
async function fetchSessionUser(): Promise<User | null> {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as {
user?: {
id?: string
uuid?: string
email: string
name?: string
username?: string
mfaEnabled?: boolean
mfaPending?: boolean
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
} | null
}
const sessionUser = payload?.user
if (!sessionUser) {
return null
}
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending, role, groups, permissions } = sessionUser
const identifier =
typeof uuid === 'string' && uuid.trim().length > 0
? uuid.trim()
: typeof id === 'string'
? id.trim()
: ''
if (!identifier) {
return null
}
const normalizedName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined
const normalizedUsername =
typeof username === 'string' && username.trim().length > 0 ? username.trim() : normalizedName
const normalizedMfa = mfa
? {
...mfa,
totpEnabled: Boolean(mfa.totpEnabled ?? mfaEnabled),
totpPending: Boolean(mfa.totpPending ?? mfaPending) && !Boolean(mfa.totpEnabled ?? mfaEnabled),
}
: {
totpEnabled: Boolean(mfaEnabled),
totpPending: Boolean(mfaPending) && !Boolean(mfaEnabled),
}
const normalizedRole = normalizeRole(role)
const normalizedGroups = Array.isArray(groups)
? groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedPermissions = Array.isArray(permissions)
? permissions
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
? sessionUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(sessionUser.tenants)
? sessionUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const normalizedTenant: TenantMembership = {
id: identifier,
}
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
normalizedTenant.name = tenant.name.trim()
}
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
normalizedTenant.role = normalizeRole(tenant.role)
}
return normalizedTenant
})
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
return {
id: identifier,
uuid: identifier,
email,
name: normalizedName,
username: normalizedUsername ?? email,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa,
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)
return null
}
}
export const useUserStore = create<UserStore>((set, get) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
hydrateFromAPI: async () => {
set({ isLoading: true })
const sessionUser = await fetchSessionUser()
set({ user: sessionUser, isLoading: false })
return sessionUser
},
refresh: async () => get().hydrateFromAPI(),
login: async () => {
await get().hydrateFromAPI()
},
logout: async () => {
try {
await fetch('/api/auth/session', {
method: 'DELETE',
credentials: 'include',
})
} catch (error) {
console.warn('Failed to clear user session', error)
}
await get().hydrateFromAPI()
},
}))
if (typeof window !== 'undefined') {
useUserStore.getState().hydrateFromAPI().catch((error) => {
console.warn('User store hydration failed', error)
useUserStore.setState({ isLoading: false })
})
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,97 @@
/**
* Tailwind CSS 配置文件
* 使用 ES Module 格式 - 统一现代标准
*
* 参考: https://tailwindcss.com/docs/configuration
*/
import typography from '@tailwindcss/typography'
const tailwindConfig = {
// 扫描的源文件路径
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
// 主题扩展配置
theme: {
extend: {
colors: {
border: 'var(--color-surface-border)',
input: 'var(--color-surface-border)',
ring: 'var(--color-ring)',
background: 'var(--color-background)',
foreground: 'var(--color-text)',
primary: {
DEFAULT: 'var(--color-primary)',
foreground: 'var(--color-primary-foreground)',
},
secondary: {
DEFAULT: 'var(--color-surface-muted)',
foreground: 'var(--color-text-muted)',
},
destructive: {
DEFAULT: 'var(--color-danger)',
foreground: 'var(--color-danger-foreground)',
},
muted: {
DEFAULT: 'var(--color-surface-muted)',
foreground: 'var(--color-text-muted)',
},
accent: {
DEFAULT: 'var(--color-accent)',
foreground: 'var(--color-accent-foreground)',
},
popover: {
DEFAULT: 'var(--color-surface-elevated)',
foreground: 'var(--color-text)',
},
card: {
DEFAULT: 'var(--color-surface)',
foreground: 'var(--color-text)',
},
brand: {
DEFAULT: '#3366FF', // 主色
light: '#4D7AFF', // 浅色
dark: '#254EDB', // 深色
surface: '#F5F8FF', // 表面色
border: '#D6E0FF', // 边框色
navy: '#1E2E55', // 海军蓝
heading: '#2E3A59', // 标题色
},
surface: {
DEFAULT: 'var(--color-surface)',
muted: 'var(--color-surface-muted)',
border: 'var(--color-surface-border)',
hover: 'var(--color-surface-hover)',
},
text: {
DEFAULT: 'var(--color-text)',
muted: 'var(--color-text-muted)',
subtle: 'var(--color-text-subtle)',
},
heading: 'var(--color-heading)',
},
// 字体配置
fontFamily: {
sans: ['var(--font-geist-sans)', 'sans-serif'],
mono: ['var(--font-geist-mono)', 'monospace'],
},
// 自定义阴影
boxShadow: {
soft: '0 35px 80px -45px rgba(37, 78, 219, 0.35), 0 25px 60px -40px rgba(15, 23, 42, 0.25)',
},
},
},
// 插件
plugins: [
typography,
],
}
export default tailwindConfig

47
workbench/tsconfig.json Normal file
View File

@ -0,0 +1,47 @@
{
"compilerOptions": {
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"isolatedModules": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"baseUrl": ".", // 👈
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"],
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@i18n/*": ["src/i18n/*"],
"@lib/*": ["src/lib/*"],
"@types/*": ["types/*"],
"@server/*": ["src/server/*"],
"@modules/*": ["src/modules/*"],
"@extensions/*": ["src/modules/extensions/*"],
"@theme": ["src/components/theme"],
"@theme/*": ["src/components/theme/*"],
"@templates/*": ["src/modules/templates/*"],
"@src/*": ["src/*"]
},
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"plugins": [{ "name": "next" }]
},
"include": [
"next-env.d.ts",
"src",
"tests",
"scripts",
"types",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}