merge: resolve README.md conflict and unify insight features
This commit is contained in:
commit
83b46f4e76
54
Makefile
54
Makefile
@ -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
114
README.md
@ -1,9 +1,9 @@
|
||||
# Observability.svc.plus
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/pgsty/pigsty)
|
||||
[](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:
|
||||
|
||||
[](https://pgext.cloud)
|
||||
|
||||
|
||||
## Get Started
|
||||
|
||||
[](https://svc.plus/docs/pgsql)
|
||||
[](https://svc.plus/docs/node)
|
||||
[](https://svc.plus/docs/node)
|
||||
[](https://svc.plus/docs/ref/linux#el)
|
||||
[](https://svc.plus/docs/ref/linux#debian)
|
||||
[](https://svc.plus/docs/ref/linux#ubuntu)
|
||||
[](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.
|
||||
|
||||
[](https://svc.plus/docs/concept/arch)
|
||||
|
||||
[](https://svc.plus/docs/pgsql) Self-healing PostgreSQL HA cluster powered by Patroni, Pgbouncer, PgBackrest & HAProxy
|
||||
Integrated as a platform.
|
||||
|
||||
[](https://svc.plus/docs/infra) Nginx, Local Repo, DNSMasq, and the entire Victoria & Grafana observability stack.
|
||||
|
||||
[](https://svc.plus/docs/node) Init node name, repo, pkg, NTP, ssh, admin, tune, expose services, collect logs & metrics.
|
||||
|
||||
[](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.
|
||||
|
||||
[](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:
|
||||
|
||||
[](https://svc.plus/docs/minio) S3-compatible object storage service; used as an optional central backup server for `PGSQL`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
#--------------------------------------------------------------#
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
17
roles/insight/defaults/main.yml
Normal file
17
roles/insight/defaults/main.yml
Normal 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?
|
||||
81
roles/insight/tasks/main.yml
Normal file
81
roles/insight/tasks/main.yml
Normal 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
|
||||
|
||||
...
|
||||
17
roles/insight/templates/insight.service.j2
Normal file
17
roles/insight/templates/insight.service.j2
Normal 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
10
workbench/.env.local
Normal 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
125
workbench/next.config.mjs
Normal 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
107
workbench/package.json
Normal 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"
|
||||
}
|
||||
14
workbench/postcss.config.mjs
Normal file
14
workbench/postcss.config.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* PostCSS 配置文件
|
||||
* 使用 ES Module 格式 - 统一现代标准
|
||||
*
|
||||
* 参考: https://postcss.org/
|
||||
*/
|
||||
|
||||
export default {
|
||||
// 插件列表
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
75
workbench/src/app/globals.css
Normal file
75
workbench/src/app/globals.css
Normal 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;
|
||||
}
|
||||
37
workbench/src/app/layout.tsx
Normal file
37
workbench/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
workbench/src/app/page.tsx
Normal file
7
workbench/src/app/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import InsightWorkbench from "@/components/insight/InsightWorkbench";
|
||||
|
||||
export default function Home() {
|
||||
return <InsightWorkbench />;
|
||||
}
|
||||
375
workbench/src/components/insight/InsightWorkbench.tsx
Normal file
375
workbench/src/components/insight/InsightWorkbench.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
workbench/src/components/insight/ai/Assistant.tsx
Normal file
225
workbench/src/components/insight/ai/Assistant.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
workbench/src/components/insight/explore/ExploreBuilder.tsx
Normal file
341
workbench/src/components/insight/explore/ExploreBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
workbench/src/components/insight/layout/BreadcrumbBar.tsx
Normal file
90
workbench/src/components/insight/layout/BreadcrumbBar.tsx
Normal 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>
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
workbench/src/components/insight/layout/Sidebar.tsx
Normal file
31
workbench/src/components/insight/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
workbench/src/components/insight/layout/TimeRangePicker.tsx
Normal file
33
workbench/src/components/insight/layout/TimeRangePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
workbench/src/components/insight/layout/WorkspaceGrid.tsx
Normal file
88
workbench/src/components/insight/layout/WorkspaceGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
workbench/src/components/insight/layout/WorkspaceHeader.tsx
Normal file
44
workbench/src/components/insight/layout/WorkspaceHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
workbench/src/components/insight/services/adapters/logs.ts
Normal file
38
workbench/src/components/insight/services/adapters/logs.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
62
workbench/src/components/insight/services/adapters/traces.ts
Normal file
62
workbench/src/components/insight/services/adapters/traces.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
19
workbench/src/components/insight/services/correlator.ts
Normal file
19
workbench/src/components/insight/services/correlator.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
28
workbench/src/components/insight/services/gitops.ts
Normal file
28
workbench/src/components/insight/services/gitops.ts
Normal 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
|
||||
}
|
||||
}
|
||||
120
workbench/src/components/insight/slo/AlertWizard.tsx
Normal file
120
workbench/src/components/insight/slo/AlertWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
workbench/src/components/insight/slo/SLOPanel.tsx
Normal file
127
workbench/src/components/insight/slo/SLOPanel.tsx
Normal 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'
|
||||
}
|
||||
96
workbench/src/components/insight/snippets/SnippetLibrary.tsx
Normal file
96
workbench/src/components/insight/snippets/SnippetLibrary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
209
workbench/src/components/insight/store/urlState.ts
Normal file
209
workbench/src/components/insight/store/urlState.ts
Normal 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
|
||||
}
|
||||
154
workbench/src/components/insight/store/useInsightState.ts
Normal file
154
workbench/src/components/insight/store/useInsightState.ts
Normal 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()
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
189
workbench/src/components/insight/topology/TopologyCanvas.tsx
Normal file
189
workbench/src/components/insight/topology/TopologyCanvas.tsx
Normal 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'
|
||||
}
|
||||
16
workbench/src/components/insight/topology/types.ts
Normal file
16
workbench/src/components/insight/topology/types.ts
Normal 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
|
||||
}
|
||||
49
workbench/src/components/insight/viz/LogsTable.tsx
Normal file
49
workbench/src/components/insight/viz/LogsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
workbench/src/components/insight/viz/LogsTopStats.tsx
Normal file
59
workbench/src/components/insight/viz/LogsTopStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
workbench/src/components/insight/viz/LogsViewer.tsx
Normal file
36
workbench/src/components/insight/viz/LogsViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
workbench/src/components/insight/viz/MetricsChart.tsx
Normal file
48
workbench/src/components/insight/viz/MetricsChart.tsx
Normal 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']
|
||||
66
workbench/src/components/insight/viz/MetricsTable.tsx
Normal file
66
workbench/src/components/insight/viz/MetricsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
workbench/src/components/insight/viz/MetricsTopStats.tsx
Normal file
45
workbench/src/components/insight/viz/MetricsTopStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
workbench/src/components/insight/viz/TracesTable.tsx
Normal file
48
workbench/src/components/insight/viz/TracesTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
workbench/src/components/insight/viz/TracesTopStats.tsx
Normal file
42
workbench/src/components/insight/viz/TracesTopStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
workbench/src/components/insight/viz/TracesWaterfall.tsx
Normal file
41
workbench/src/components/insight/viz/TracesWaterfall.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
workbench/src/components/insight/viz/VizArea.tsx
Normal file
134
workbench/src/components/insight/viz/VizArea.tsx
Normal 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'
|
||||
}
|
||||
47
workbench/src/components/layout/SidebarRoot.tsx
Normal file
47
workbench/src/components/layout/SidebarRoot.tsx
Normal 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>
|
||||
}
|
||||
87
workbench/src/i18n/LanguageProvider.tsx
Normal file
87
workbench/src/i18n/LanguageProvider.tsx
Normal 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 }
|
||||
}
|
||||
1918
workbench/src/i18n/translations.ts
Normal file
1918
workbench/src/i18n/translations.ts
Normal file
File diff suppressed because it is too large
Load Diff
35
workbench/src/lib/moltbotStore.ts
Normal file
35
workbench/src/lib/moltbotStore.ts
Normal 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",
|
||||
}
|
||||
)
|
||||
);
|
||||
248
workbench/src/lib/userStore.ts
Normal file
248
workbench/src/lib/userStore.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
6
workbench/src/lib/utils.ts
Normal file
6
workbench/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
97
workbench/tailwind.config.js
Normal file
97
workbench/tailwind.config.js
Normal 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
47
workbench/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user