feat: wire dashboard to live runtime status
This commit is contained in:
parent
9895d77dbf
commit
a8b5b25d84
1
.gitignore
vendored
1
.gitignore
vendored
@ -36,6 +36,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
dashboard/node_modules/
|
dashboard/node_modules/
|
||||||
dashboard/dist/
|
dashboard/dist/
|
||||||
|
dashboard/public/status.json
|
||||||
/dashboard-preview.png
|
/dashboard-preview.png
|
||||||
/dashboard-status-dropdown.png
|
/dashboard-status-dropdown.png
|
||||||
|
|
||||||
|
|||||||
23
api/command.go
Normal file
23
api/command.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func commandState(name string, args ...string) string {
|
||||||
|
out := commandOutput(name, args...)
|
||||||
|
if out == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandOutput(name string, args ...string) string {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
108
api/main.go
108
api/main.go
@ -1,113 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "log"
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Unit string `json:"unit"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HealthResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Arch string `json:"arch"`
|
|
||||||
OS string `json:"os"`
|
|
||||||
CPU int `json:"cpu"`
|
|
||||||
Memory string `json:"memory"`
|
|
||||||
Disk string `json:"disk"`
|
|
||||||
Services []Service `json:"services"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/health", healthHandler)
|
|
||||||
mux.HandleFunc("/services", servicesHandler)
|
|
||||||
mux.HandleFunc("/metrics/simple", metricsHandler)
|
|
||||||
|
|
||||||
addr := "127.0.0.1:8788"
|
addr := "127.0.0.1:8788"
|
||||||
log.Printf("xworkspace api listening on %s", addr)
|
log.Printf("xworkspace api listening on %s", addr)
|
||||||
log.Fatal(http.ListenAndServe(addr, withCORS(mux)))
|
log.Fatal(NewServer().ListenAndServe(addr))
|
||||||
}
|
|
||||||
|
|
||||||
func withCORS(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, HealthResponse{
|
|
||||||
Status: "ok",
|
|
||||||
Arch: runtime.GOARCH,
|
|
||||||
OS: runtime.GOOS,
|
|
||||||
CPU: runtime.NumCPU(),
|
|
||||||
Memory: "unknown",
|
|
||||||
Disk: "unknown",
|
|
||||||
Services: probeServices(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func servicesHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, probeServices())
|
|
||||||
}
|
|
||||||
|
|
||||||
func metricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(probeServices())) + "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeServices() []Service {
|
|
||||||
units := []string{
|
|
||||||
"xworkspace-console.service",
|
|
||||||
"xworkspace-openclaw.service",
|
|
||||||
"xworkspace-bridge.service",
|
|
||||||
"xworkspace-litellm.service",
|
|
||||||
"xworkspace-vault.service",
|
|
||||||
}
|
|
||||||
services := make([]Service, 0, len(units))
|
|
||||||
for _, unit := range units {
|
|
||||||
state := commandState("systemctl", "--user", "is-active", unit)
|
|
||||||
if state == "" {
|
|
||||||
state = "unknown"
|
|
||||||
}
|
|
||||||
services = append(services, Service{
|
|
||||||
Name: strings.TrimSuffix(unit, ".service"),
|
|
||||||
Unit: unit,
|
|
||||||
State: state,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandState(name string, args ...string) string {
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
if err := enc.Encode(v); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
69
api/metric_probe.go
Normal file
69
api/metric_probe.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetricProbe interface {
|
||||||
|
Probe(services []Service) Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemMetricProbe struct{}
|
||||||
|
|
||||||
|
func NewMetricProbe() MetricProbe {
|
||||||
|
return SystemMetricProbe{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p SystemMetricProbe) Probe(services []Service) Metrics {
|
||||||
|
return Metrics{
|
||||||
|
ActiveSessions: countProcesses("codex", "openclaw", "opencode", "gemini", "hermes"),
|
||||||
|
ConnectedAgents: countConnectedAgents(services),
|
||||||
|
ActiveModels: countLiteLLMModels(),
|
||||||
|
SkillsAvailable: countSkillDirs(),
|
||||||
|
Workers: countProcesses("codex", "openclaw", "opencode", "gemini", "hermes", "xworkmate-go-core"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countConnectedAgents(services []Service) int {
|
||||||
|
count := 0
|
||||||
|
for _, service := range services {
|
||||||
|
name := strings.ToLower(service.Name)
|
||||||
|
if service.State == "active" && (strings.Contains(name, "bridge") || strings.Contains(name, "openclaw")) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func countProcesses(names ...string) int {
|
||||||
|
out := commandOutput("pgrep", "-af", strings.Join(names, "|"))
|
||||||
|
if out == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" && !strings.Contains(line, "pgrep -af") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLiteLLMModels() int {
|
||||||
|
out := commandOutput("sh", "-lc", "curl -fsS --max-time 1 http://127.0.0.1:4000/v1/models 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"data\", [])))' 2>/dev/null")
|
||||||
|
return parseIntOrZero(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countSkillDirs() int {
|
||||||
|
out := commandOutput("sh", "-lc", "find \"$HOME/.openclaw/workspace/skills\" \"$HOME/.codex/skills\" \"$HOME/.agents/skills\" -name SKILL.md 2>/dev/null | wc -l")
|
||||||
|
return parseIntOrZero(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntOrZero(value string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
91
api/server.go
Normal file
91
api/server.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
serviceProbe ServiceProbe
|
||||||
|
metricProbe MetricProbe
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer() *Server {
|
||||||
|
serviceProbe := NewServiceProbe()
|
||||||
|
return &Server{
|
||||||
|
serviceProbe: serviceProbe,
|
||||||
|
metricProbe: NewMetricProbe(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListenAndServe(addr string) error {
|
||||||
|
return http.ListenAndServe(addr, s.withCORS(s.routes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
|
mux.HandleFunc("/services", s.servicesHandler)
|
||||||
|
mux.HandleFunc("/metrics/simple", s.metricsHandler)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if allowedOrigin(origin) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedOrigin(origin string) bool {
|
||||||
|
switch origin {
|
||||||
|
case "http://127.0.0.1:17000", "http://localhost:17000", "http://127.0.0.1:3000", "https://console.svc.plus":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
services := s.serviceProbe.Probe()
|
||||||
|
writeJSON(w, HealthResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
CPU: runtime.NumCPU(),
|
||||||
|
Memory: "unknown",
|
||||||
|
Disk: "unknown",
|
||||||
|
Services: services,
|
||||||
|
Metrics: s.metricProbe.Probe(services),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, s.serviceProbe.Probe())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
api/service_probe.go
Normal file
96
api/service_probe.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceProbe interface {
|
||||||
|
Probe() []Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceProbeConfig struct {
|
||||||
|
Name string
|
||||||
|
Units []string
|
||||||
|
Port int
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemServiceProbe struct {
|
||||||
|
services []serviceProbeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceProbe() ServiceProbe {
|
||||||
|
return SystemServiceProbe{
|
||||||
|
services: []serviceProbeConfig{
|
||||||
|
{Name: "XWorkspace Console", Units: []string{"xworkspace-console.service"}, Port: 17000, URL: "http://127.0.0.1:17000"},
|
||||||
|
{Name: "OpenClaw Gateway", Units: []string{"xworkspace-openclaw.service", "openclaw-gateway.service"}, Port: 18789, URL: "http://127.0.0.1:18789/channels"},
|
||||||
|
{Name: "XWorkmate Bridge", Units: []string{"xworkspace-bridge.service", "xworkmate-bridge.service"}, Port: 8787, URL: "http://127.0.0.1:8787/api/ping"},
|
||||||
|
{Name: "LiteLLM", Units: []string{"xworkspace-litellm.service", "litellm-proxy.service"}, Port: 4000, URL: "http://127.0.0.1:4000/ui"},
|
||||||
|
{Name: "Vault", Units: []string{"xworkspace-vault.service", "vault.service"}, Port: 8200, URL: "http://127.0.0.1:8200/ui/"},
|
||||||
|
{Name: "Terminal", Units: []string{"xworkspace-ttyd.service", "ttyd.service"}, Port: 7681, URL: "http://127.0.0.1:7681"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p SystemServiceProbe) Probe() []Service {
|
||||||
|
services := make([]Service, 0, len(p.services))
|
||||||
|
for _, config := range p.services {
|
||||||
|
unit, state := firstSystemdUnit(config.Units)
|
||||||
|
portOpen := config.Port > 0 && isPortOpen("127.0.0.1", config.Port)
|
||||||
|
if state == "" && portOpen {
|
||||||
|
state = "active"
|
||||||
|
} else if state == "" {
|
||||||
|
state = "inactive"
|
||||||
|
}
|
||||||
|
services = append(services, Service{
|
||||||
|
Name: config.Name,
|
||||||
|
Unit: unit,
|
||||||
|
State: state,
|
||||||
|
Detail: probeDetail(unit, portOpen),
|
||||||
|
Port: config.Port,
|
||||||
|
URL: config.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstSystemdUnit(units []string) (string, string) {
|
||||||
|
for _, unit := range units {
|
||||||
|
if state := commandState("systemctl", "is-active", unit); state != "" && state != "unknown" {
|
||||||
|
return unit, state
|
||||||
|
}
|
||||||
|
if state := commandState("systemctl", "--user", "is-active", unit); state != "" && state != "unknown" {
|
||||||
|
return unit, state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unit := range units {
|
||||||
|
if state := commandState("systemctl", "is-enabled", unit); state != "" && state != "unknown" {
|
||||||
|
return unit, ""
|
||||||
|
}
|
||||||
|
if state := commandState("systemctl", "--user", "is-enabled", unit); state != "" && state != "unknown" {
|
||||||
|
return unit, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeDetail(unit string, portOpen bool) string {
|
||||||
|
if unit == "" && portOpen {
|
||||||
|
return "port open"
|
||||||
|
}
|
||||||
|
if portOpen {
|
||||||
|
return "systemd + port open"
|
||||||
|
}
|
||||||
|
return "systemd"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPortOpen(host string, port int) bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 300*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
29
api/types.go
Normal file
29
api/types.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
CPU int `json:"cpu"`
|
||||||
|
Memory string `json:"memory"`
|
||||||
|
Disk string `json:"disk"`
|
||||||
|
Services []Service `json:"services"`
|
||||||
|
Metrics Metrics `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
ActiveSessions int `json:"activeSessions"`
|
||||||
|
ConnectedAgents int `json:"connectedAgents"`
|
||||||
|
ActiveModels int `json:"activeModels"`
|
||||||
|
SkillsAvailable int `json:"skillsAvailable"`
|
||||||
|
Workers int `json:"workers"`
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { agents, customWorkspaceTabs, initialTabs, labelsEn, labelsZh, mockServices, tasks } from '@/lib/data';
|
import { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh, mockServices } from '@/lib/data';
|
||||||
import type { NavItem, Service, Tab } from '@/lib/data';
|
import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data';
|
||||||
import { fetchServices } from '@/lib/api';
|
import { fetchDashboardStatus } from '@/lib/api';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
import { WorkspaceTabs } from './WorkspaceTabs';
|
import { WorkspaceTabs } from './WorkspaceTabs';
|
||||||
@ -14,13 +14,27 @@ export function AppShell() {
|
|||||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||||
const [tabs, setTabs] = useState<Tab[]>(initialTabs);
|
const [tabs, setTabs] = useState<Tab[]>(initialTabs);
|
||||||
const [services, setServices] = useState<Service[] | null>(null);
|
const [services, setServices] = useState<Service[] | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<RuntimeMetrics>(fallbackMetrics);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [language, setLanguage] = useState<'en' | 'zh'>('en');
|
const [language, setLanguage] = useState<'en' | 'zh'>('en');
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
const [remoteMode, setRemoteMode] = useState(true);
|
const [remoteMode, setRemoteMode] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServices().then((data) => data && setServices(data));
|
let active = true;
|
||||||
|
const refresh = () => {
|
||||||
|
fetchDashboardStatus().then((data) => {
|
||||||
|
if (!active || !data) return;
|
||||||
|
setServices(data.services);
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
const timer = window.setInterval(refresh, 15_000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -61,10 +75,8 @@ export function AppShell() {
|
|||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
||||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').length;
|
return { runningServices, runningAgents: metrics.connectedAgents, runningTasks: metrics.workers };
|
||||||
const runningTasks = tasks.filter((task) => task[2] === 'Running').length;
|
}, [currentServices, metrics]);
|
||||||
return { runningServices, runningAgents, runningTasks };
|
|
||||||
}, [currentServices]);
|
|
||||||
|
|
||||||
const openTab = (item: NavItem | Tab) => {
|
const openTab = (item: NavItem | Tab) => {
|
||||||
setTabs((existingTabs) => {
|
setTabs((existingTabs) => {
|
||||||
@ -108,6 +120,7 @@ export function AppShell() {
|
|||||||
selectedLabel={selected && selected.id !== 'workspace' ? selected.label : null}
|
selectedLabel={selected && selected.id !== 'workspace' ? selected.label : null}
|
||||||
services={currentServices}
|
services={currentServices}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
|
metrics={metrics}
|
||||||
onToggleSidebar={() => setSidebarCollapsed((value) => !value)}
|
onToggleSidebar={() => setSidebarCollapsed((value) => !value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -116,7 +129,7 @@ export function AppShell() {
|
|||||||
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
||||||
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
|
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
|
||||||
) : (
|
) : (
|
||||||
<WorkspaceHome labels={labels} services={currentServices} onOpenService={openTab} />
|
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} onOpenService={openTab} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { acpAgents, agents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
|
import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
|
||||||
import type { Labels, NavItem, Service } from '@/lib/data';
|
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export function ArchPipeline({
|
export function ArchPipeline({
|
||||||
labels,
|
labels,
|
||||||
services,
|
services,
|
||||||
|
metrics,
|
||||||
onOpenService,
|
onOpenService,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
|
metrics: RuntimeMetrics;
|
||||||
onOpenService: (item: NavItem) => void;
|
onOpenService: (item: NavItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||||
@ -24,8 +26,8 @@ export function ArchPipeline({
|
|||||||
const dot = (state?: Service['state']) => (
|
const dot = (state?: Service['state']) => (
|
||||||
<i className={state === 'Running' ? 'dot good' : state ? 'dot bad' : 'dot idle'} />
|
<i className={state === 'Running' ? 'dot good' : state ? 'dot bad' : 'dot idle'} />
|
||||||
);
|
);
|
||||||
const runningAgents = agents.filter((agent) => agent.state === 'Running').map((agent) => agent.name.split(' ')[0]);
|
const runningAgents = new Set(acpAgents.slice(0, metrics.connectedAgents));
|
||||||
const totalSkills = skillGroups.reduce((count, group) => count + group.skills.length, 0);
|
const totalSkills = metrics.skillsAvailable || skillGroups.reduce((count, group) => count + group.skills.length, 0);
|
||||||
const externalModels = [
|
const externalModels = [
|
||||||
{ name: 'GPT-5.5', mark: '◎', tone: 'openai' },
|
{ name: 'GPT-5.5', mark: '◎', tone: 'openai' },
|
||||||
{ name: 'DeepSeek V4', mark: 'D', tone: 'deepseek' },
|
{ name: 'DeepSeek V4', mark: 'D', tone: 'deepseek' },
|
||||||
@ -64,7 +66,7 @@ export function ArchPipeline({
|
|||||||
<div className="node-head">
|
<div className="node-head">
|
||||||
<span className="node-index purple">2</span>
|
<span className="node-index purple">2</span>
|
||||||
<strong>{labels.agentBand}</strong>
|
<strong>{labels.agentBand}</strong>
|
||||||
<small>4 {labels.sessions} · 8 {labels.workers}</small>
|
<small>{metrics.activeSessions} {labels.sessions} · {metrics.workers} {labels.workers}</small>
|
||||||
{dot()}
|
{dot()}
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-icons">
|
<div className="agent-icons">
|
||||||
@ -84,7 +86,7 @@ export function ArchPipeline({
|
|||||||
<strong>{labels.acpCard}</strong>
|
<strong>{labels.acpCard}</strong>
|
||||||
<div className="router-grid">
|
<div className="router-grid">
|
||||||
{acpAgents.map((agent) => (
|
{acpAgents.map((agent) => (
|
||||||
<em key={agent} className={runningAgents.includes(agent) ? 'busy' : ''}>{agent}</em>
|
<em key={agent} className={runningAgents.has(agent) ? 'busy' : ''}>{agent}</em>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -95,7 +97,7 @@ export function ArchPipeline({
|
|||||||
<div className="node-head">
|
<div className="node-head">
|
||||||
<span className="node-index green">3</span>
|
<span className="node-index green">3</span>
|
||||||
<strong>{labels.skillBand} Layer</strong>
|
<strong>{labels.skillBand} Layer</strong>
|
||||||
<small>{totalSkills * 2}+ {labels.skillsCount}</small>
|
<small>{totalSkills}+ {labels.skillsCount}</small>
|
||||||
{dot(stateOf('bridge'))}
|
{dot(stateOf('bridge'))}
|
||||||
</div>
|
</div>
|
||||||
<div className="skill-stack">
|
<div className="skill-stack">
|
||||||
@ -111,10 +113,10 @@ export function ArchPipeline({
|
|||||||
|
|
||||||
<aside className="workspace-status node-card">
|
<aside className="workspace-status node-card">
|
||||||
<strong>{labels.workspaceStatus}</strong>
|
<strong>{labels.workspaceStatus}</strong>
|
||||||
<div><span>{labels.sessions}</span><b>333</b><small>Active</small><Icon name="user" /></div>
|
<div><span>{labels.sessions}</span><b>{metrics.activeSessions}</b><small>Active</small><Icon name="user" /></div>
|
||||||
<div><span>Agents</span><b>7</b><small>Connected</small><Icon name="bot" /></div>
|
<div><span>Agents</span><b>{metrics.connectedAgents}</b><small>Connected</small><Icon name="bot" /></div>
|
||||||
<div><span>Memory</span><b>Enabled</b><Icon name="database" /></div>
|
<div><span>Memory</span><b>Enabled</b><Icon name="database" /></div>
|
||||||
<div><span>Skills</span><b>30+</b><Icon name="sparkles" /></div>
|
<div><span>Skills</span><b>{totalSkills}+</b><Icon name="sparkles" /></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<button type="button" className="model-layer node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'litellm')!)}>
|
<button type="button" className="model-layer node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'litellm')!)}>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Icon } from './Icon';
|
|||||||
|
|
||||||
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const frameBlocked = tab.frameMode === 'external';
|
||||||
return (
|
return (
|
||||||
<div className="workspace-body">
|
<div className="workspace-body">
|
||||||
<section className="embed-panel">
|
<section className="embed-panel">
|
||||||
@ -16,7 +17,7 @@ export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
|||||||
<strong>{tab.label}</strong>
|
<strong>{tab.label}</strong>
|
||||||
<span className="embed-url" title={tab.href}>{tab.href}</span>
|
<span className="embed-url" title={tab.href}>{tab.href}</span>
|
||||||
<div className="embed-toolbar-actions">
|
<div className="embed-toolbar-actions">
|
||||||
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)}>
|
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={frameBlocked}>
|
||||||
<Icon name="refresh" />
|
<Icon name="refresh" />
|
||||||
</button>
|
</button>
|
||||||
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
|
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
|
||||||
@ -24,14 +25,25 @@ export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe
|
{frameBlocked ? (
|
||||||
key={reloadKey}
|
<div className="external-embed-fallback">
|
||||||
title={`${tab.label} workspace`}
|
<div>
|
||||||
src={tab.href}
|
<span className="external-embed-icon"><Icon name={tab.icon ?? 'external'} /></span>
|
||||||
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
|
<strong>{tab.label}</strong>
|
||||||
allowFullScreen
|
<p>This service blocks embedded frames. Open it in a dedicated browser tab.</p>
|
||||||
referrerPolicy="no-referrer"
|
<a href={tab.href} target="_blank" rel="noreferrer">Open {tab.label}</a>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
key={reloadKey}
|
||||||
|
title={`${tab.label} workspace`}
|
||||||
|
src={tab.href}
|
||||||
|
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { Labels, Service } from '@/lib/data';
|
import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { StatusAggregate } from './StatusAggregate';
|
import { StatusAggregate } from './StatusAggregate';
|
||||||
|
|
||||||
@ -10,12 +10,14 @@ export function Topbar({
|
|||||||
selectedLabel,
|
selectedLabel,
|
||||||
services,
|
services,
|
||||||
summary,
|
summary,
|
||||||
|
metrics,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
selectedLabel: string | null;
|
selectedLabel: string | null;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
summary: { runningServices: number; runningAgents: number };
|
summary: { runningServices: number; runningAgents: number };
|
||||||
|
metrics: RuntimeMetrics;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
}) {
|
}) {
|
||||||
const breadcrumbItems = [labels.product, labels.workspace, selectedLabel].filter(Boolean) as string[];
|
const breadcrumbItems = [labels.product, labels.workspace, selectedLabel].filter(Boolean) as string[];
|
||||||
@ -45,7 +47,7 @@ export function Topbar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="status-strip">
|
<div className="status-strip">
|
||||||
<StatusAggregate labels={labels} services={services} summary={summary} />
|
<StatusAggregate labels={labels} services={services} summary={summary} />
|
||||||
<span className="status-pill"><Icon name="user" />333 Sessions</span>
|
<span className="status-pill"><Icon name="user" />{metrics.activeSessions} Sessions</span>
|
||||||
<span className="status-pill"><Icon name="clock" />{time}</span>
|
<span className="status-pill"><Icon name="clock" />{time}</span>
|
||||||
<button className="round-button" type="button" aria-label="Notifications">
|
<button className="round-button" type="button" aria-label="Notifications">
|
||||||
<Icon name="bell" />
|
<Icon name="bell" />
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import type { Labels, NavItem, Service } from '@/lib/data';
|
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { ArchPipeline } from './ArchPipeline';
|
import { ArchPipeline } from './ArchPipeline';
|
||||||
|
|
||||||
export function WorkspaceHome({
|
export function WorkspaceHome({
|
||||||
labels,
|
labels,
|
||||||
services,
|
services,
|
||||||
|
metrics,
|
||||||
onOpenService,
|
onOpenService,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
|
metrics: RuntimeMetrics;
|
||||||
onOpenService: (item: NavItem) => void;
|
onOpenService: (item: NavItem) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -21,7 +23,7 @@ export function WorkspaceHome({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArchPipeline labels={labels} services={services} onOpenService={onOpenService} />
|
<ArchPipeline labels={labels} services={services} metrics={metrics} onOpenService={onOpenService} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,17 +1,50 @@
|
|||||||
import type { Service } from './data';
|
import type { DashboardStatus, Service } from './data';
|
||||||
|
|
||||||
const BASE = 'http://127.0.0.1:8788';
|
const BASE = 'http://127.0.0.1:8788';
|
||||||
|
|
||||||
|
const normalizeServiceState = (state?: string): Service['state'] => {
|
||||||
|
if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
|
||||||
|
if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped';
|
||||||
|
return 'Degraded';
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapService = (item: { name?: string; unit?: string; state?: string; detail?: string; port?: number; url?: string }): Service => ({
|
||||||
|
name: item.name ?? item.unit ?? 'unknown',
|
||||||
|
unit: item.unit,
|
||||||
|
detail: item.detail,
|
||||||
|
port: item.port,
|
||||||
|
url: item.url,
|
||||||
|
state: normalizeServiceState(item.state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function fetchDashboardStatus(): Promise<DashboardStatus | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE}/health`, { cache: 'no-store' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data.services)) return null;
|
||||||
|
return {
|
||||||
|
services: data.services.map(mapService),
|
||||||
|
metrics: {
|
||||||
|
activeSessions: Number(data.metrics?.activeSessions ?? 0),
|
||||||
|
connectedAgents: Number(data.metrics?.connectedAgents ?? 0),
|
||||||
|
activeModels: Number(data.metrics?.activeModels ?? 0),
|
||||||
|
skillsAvailable: Number(data.metrics?.skillsAvailable ?? 0),
|
||||||
|
workers: Number(data.metrics?.workers ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchServices(): Promise<Service[] | null> {
|
export async function fetchServices(): Promise<Service[] | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE}/services`, { cache: 'no-store' });
|
const response = await fetch(`${BASE}/services`, { cache: 'no-store' });
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!Array.isArray(data)) return null;
|
if (!Array.isArray(data)) return null;
|
||||||
return data.map((item: { name?: string; unit?: string; state?: string }) => ({
|
return data.map(mapService);
|
||||||
name: item.name ?? item.unit ?? 'unknown',
|
|
||||||
state: item.state === 'active' ? 'Running' : item.state === 'inactive' ? 'Stopped' : 'Degraded',
|
|
||||||
}));
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,29 @@ export type Tab = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
source?: 'builtin' | 'custom';
|
source?: 'builtin' | 'custom';
|
||||||
|
frameMode?: 'iframe' | 'external';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Service = {
|
export type Service = {
|
||||||
name: string;
|
name: string;
|
||||||
state: 'Running' | 'Degraded' | 'Stopped';
|
state: 'Running' | 'Degraded' | 'Stopped';
|
||||||
|
unit?: string;
|
||||||
|
detail?: string;
|
||||||
|
port?: number;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeMetrics = {
|
||||||
|
activeSessions: number;
|
||||||
|
connectedAgents: number;
|
||||||
|
activeModels: number;
|
||||||
|
skillsAvailable: number;
|
||||||
|
workers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardStatus = {
|
||||||
|
services: Service[];
|
||||||
|
metrics: RuntimeMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
@ -25,12 +43,13 @@ export type ServiceDef = NavItem & {
|
|||||||
group: number;
|
group: number;
|
||||||
port?: number;
|
port?: number;
|
||||||
match?: string[];
|
match?: string[];
|
||||||
|
frameMode?: Tab['frameMode'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serviceRegistry: ServiceDef[] = [
|
export const serviceRegistry: ServiceDef[] = [
|
||||||
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
|
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
|
||||||
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:18789/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'] },
|
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:18789/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'], frameMode: 'external' },
|
||||||
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://localhost:8200', kind: 'embed', group: 1, port: 8200, match: ['vault'] },
|
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://127.0.0.1:8200/ui/', kind: 'embed', group: 1, port: 8200, match: ['vault'], frameMode: 'external' },
|
||||||
{ id: 'litellm', label: 'LiteLLM Admin UI', icon: 'chart', href: 'http://localhost:4000/ui', kind: 'embed', group: 1, port: 4000, match: ['litellm', 'lite'] },
|
{ id: 'litellm', label: 'LiteLLM Admin UI', icon: 'chart', href: 'http://localhost:4000/ui', kind: 'embed', group: 1, port: 4000, match: ['litellm', 'lite'] },
|
||||||
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] },
|
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] },
|
||||||
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 },
|
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 },
|
||||||
@ -78,6 +97,14 @@ export const mockServices: Service[] = [
|
|||||||
{ name: 'XWorkmate Bridge', state: 'Running' },
|
{ name: 'XWorkmate Bridge', state: 'Running' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const fallbackMetrics: RuntimeMetrics = {
|
||||||
|
activeSessions: 0,
|
||||||
|
connectedAgents: 0,
|
||||||
|
activeModels: 0,
|
||||||
|
skillsAvailable: 0,
|
||||||
|
workers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const agents = [
|
export const agents = [
|
||||||
{ name: 'Codex Agent', state: 'Idle', workspace: 'xworkspace-console', task: 'Homepage redesign' },
|
{ name: 'Codex Agent', state: 'Idle', workspace: 'xworkspace-console', task: 'Homepage redesign' },
|
||||||
{ name: 'Hermes Agent', state: 'Running', workspace: 'messaging', task: 'Gateway sync' },
|
{ name: 'Hermes Agent', state: 'Running', workspace: 'messaging', task: 'Gateway sync' },
|
||||||
|
|||||||
@ -925,6 +925,65 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 1440px, calc((100vh - 238px) * 1.6));
|
||||||
|
min-height: min(640px, calc(100vh - 238px));
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback > div {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-icon .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--panel);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-drawer {
|
.terminal-drawer {
|
||||||
margin: 18px 22px 28px;
|
margin: 18px 22px 28px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@ -2113,6 +2172,11 @@ button.node-card {
|
|||||||
background: var(--soft);
|
background: var(--soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.embed-tool:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
.embed-tool .icon {
|
.embed-tool .icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user