feat: configure portal services and terminal
This commit is contained in:
parent
f8a27b8058
commit
35f664856f
98
api/auth.go
Normal file
98
api/auth.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
tokens []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthConfig() AuthConfig {
|
||||||
|
return AuthConfig{tokens: loadAuthTokens()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AuthConfig) Required() bool {
|
||||||
|
return len(a.tokens) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AuthConfig) Authorize(r *http.Request) bool {
|
||||||
|
if !a.Required() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token := requestToken(r)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, expected := range a.tokens {
|
||||||
|
if constantTimeTokenEqual(token, expected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAuthTokens() []string {
|
||||||
|
candidates := []string{
|
||||||
|
os.Getenv("AI_WORKSPACE_AUTH_TOKEN"),
|
||||||
|
os.Getenv("XWORKSPACE_CONSOLE_AUTH_TOKEN"),
|
||||||
|
os.Getenv("XWORKMATE_BRIDGE_AUTH_TOKEN"),
|
||||||
|
os.Getenv("BRIDGE_AUTH_TOKEN"),
|
||||||
|
os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN"),
|
||||||
|
os.Getenv("INTERNAL_SERVICE_TOKEN"),
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
candidates = append(candidates,
|
||||||
|
readTokenFile(filepath.Join(home, ".ai_workspace_auth_token")),
|
||||||
|
readTokenFile(filepath.Join(home, ".config", "xworkspace", "auth-token")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
tokens := make([]string, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
token := strings.TrimSpace(candidate)
|
||||||
|
if token == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[token]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[token] = struct{}{}
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTokenFile(path string) string {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestToken(r *http.Request) string {
|
||||||
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
if len(header) > 7 && strings.EqualFold(header[:7], "Bearer ") {
|
||||||
|
return strings.TrimSpace(header[7:])
|
||||||
|
}
|
||||||
|
if header != "" {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(r.Header.Get("X-Bridge-Token")); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.Header.Get("X-XWorkspace-Token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeTokenEqual(actual string, expected string) bool {
|
||||||
|
if len(actual) != len(expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||||
|
}
|
||||||
114
api/portal_services.go
Normal file
114
api/portal_services.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PortalServiceProvider struct {
|
||||||
|
file string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPortalServiceProvider() PortalServiceProvider {
|
||||||
|
file := os.Getenv("XWORKSPACE_PORTAL_SERVICES_FILE")
|
||||||
|
if file == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
file = filepath.Join(home, ".config", "xworkspace", "portal-services.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PortalServiceProvider{file: file}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PortalServiceProvider) Services() []PortalService {
|
||||||
|
if services := p.loadFromFile(); len(services) > 0 {
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
return defaultPortalServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PortalServiceProvider) loadFromFile() []PortalService {
|
||||||
|
if p.file == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(p.file)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper PortalServicesResponse
|
||||||
|
if err := json.Unmarshal(content, &wrapper); err == nil && len(wrapper.Services) > 0 {
|
||||||
|
return normalizePortalServices(wrapper.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []PortalService
|
||||||
|
if err := json.Unmarshal(content, &services); err == nil && len(services) > 0 {
|
||||||
|
return normalizePortalServices(services)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePortalServices(services []PortalService) []PortalService {
|
||||||
|
normalized := make([]PortalService, 0, len(services))
|
||||||
|
for _, service := range services {
|
||||||
|
if service.Key == "" || service.Name == "" || service.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service.OpenMode != "external" {
|
||||||
|
service.OpenMode = "iframe"
|
||||||
|
}
|
||||||
|
normalized = append(normalized, service)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPortalServices() []PortalService {
|
||||||
|
return []PortalService{
|
||||||
|
{
|
||||||
|
Key: "litellm",
|
||||||
|
Name: "LiteLLM Admin UI",
|
||||||
|
URL: "http://localhost:4000/ui",
|
||||||
|
OpenMode: "iframe",
|
||||||
|
HealthURL: "http://127.0.0.1:4000/ui",
|
||||||
|
Description: "Model routing and provider administration.",
|
||||||
|
Icon: "chart",
|
||||||
|
Match: []string{"litellm", "lite"},
|
||||||
|
Port: 4000,
|
||||||
|
Role: "model-router",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "openclaw",
|
||||||
|
Name: "OpenClaw",
|
||||||
|
URL: "http://127.0.0.1:18789/channels",
|
||||||
|
OpenMode: "external",
|
||||||
|
HealthURL: "http://127.0.0.1:18789/channels",
|
||||||
|
Description: "Gateway dashboard.",
|
||||||
|
Icon: "claw",
|
||||||
|
Match: []string{"openclaw", "gateway"},
|
||||||
|
Port: 18789,
|
||||||
|
Role: "gateway",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "vault",
|
||||||
|
Name: "Vault Server",
|
||||||
|
URL: "http://127.0.0.1:8200/ui",
|
||||||
|
OpenMode: "external",
|
||||||
|
HealthURL: "http://127.0.0.1:8200/ui",
|
||||||
|
Description: "Vault UI.",
|
||||||
|
Icon: "shield",
|
||||||
|
Match: []string{"vault"},
|
||||||
|
Port: 8200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "terminal",
|
||||||
|
Name: "Terminal",
|
||||||
|
URL: "http://127.0.0.1:7681",
|
||||||
|
OpenMode: "iframe",
|
||||||
|
HealthURL: "http://127.0.0.1:7681",
|
||||||
|
Description: "Local ttyd terminal.",
|
||||||
|
Icon: "terminal",
|
||||||
|
Match: []string{"ttyd", "terminal"},
|
||||||
|
Port: 7681,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
57
api/proxy.go
57
api/proxy.go
@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewLocalProxy(target string, prefix string) http.Handler {
|
|
||||||
upstream, err := url.Parse(target)
|
|
||||||
if err != nil {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(upstream)
|
|
||||||
originalDirector := proxy.Director
|
|
||||||
proxy.Director = func(r *http.Request) {
|
|
||||||
originalDirector(r)
|
|
||||||
r.URL.Scheme = upstream.Scheme
|
|
||||||
r.URL.Host = upstream.Host
|
|
||||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
|
||||||
if r.URL.Path == "" {
|
|
||||||
r.URL.Path = "/"
|
|
||||||
}
|
|
||||||
r.Host = upstream.Host
|
|
||||||
}
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
stripFrameBlockingHeaders(resp.Header)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripFrameBlockingHeaders(header http.Header) {
|
|
||||||
header.Del("X-Frame-Options")
|
|
||||||
csp := header.Get("Content-Security-Policy")
|
|
||||||
if csp == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
header.Set("Content-Security-Policy", removeCSPDirective(csp, "frame-ancestors"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeCSPDirective(csp string, directive string) string {
|
|
||||||
parts := strings.Split(csp, ";")
|
|
||||||
kept := parts[:0]
|
|
||||||
for _, part := range parts {
|
|
||||||
trimmed := strings.TrimSpace(part)
|
|
||||||
if trimmed == "" || strings.HasPrefix(strings.ToLower(trimmed), directive) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, trimmed)
|
|
||||||
}
|
|
||||||
return strings.Join(kept, "; ")
|
|
||||||
}
|
|
||||||
@ -8,15 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
serviceProbe ServiceProbe
|
serviceProbe ServiceProbe
|
||||||
metricProbe MetricProbe
|
metricProbe MetricProbe
|
||||||
|
portalServices PortalServiceProvider
|
||||||
|
auth AuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer() *Server {
|
func NewServer() *Server {
|
||||||
serviceProbe := NewServiceProbe()
|
serviceProbe := NewServiceProbe()
|
||||||
return &Server{
|
return &Server{
|
||||||
serviceProbe: serviceProbe,
|
serviceProbe: serviceProbe,
|
||||||
metricProbe: NewMetricProbe(),
|
metricProbe: NewMetricProbe(),
|
||||||
|
portalServices: NewPortalServiceProvider(),
|
||||||
|
auth: NewAuthConfig(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,12 +30,11 @@ func (s *Server) ListenAndServe(addr string) error {
|
|||||||
|
|
||||||
func (s *Server) routes() http.Handler {
|
func (s *Server) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/auth/status", s.authStatusHandler)
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/services", s.servicesHandler)
|
mux.HandleFunc("/services", s.servicesHandler)
|
||||||
|
mux.HandleFunc("/portal/services", s.portalServicesHandler)
|
||||||
mux.HandleFunc("/metrics/simple", s.metricsHandler)
|
mux.HandleFunc("/metrics/simple", s.metricsHandler)
|
||||||
mux.Handle("/proxy/openclaw/", NewLocalProxy("http://127.0.0.1:18789", "/proxy/openclaw"))
|
|
||||||
mux.Handle("/proxy/vault/", NewLocalProxy("http://127.0.0.1:8200", "/proxy/vault"))
|
|
||||||
mux.Handle("/ui/", NewLocalProxy("http://127.0.0.1:8200", ""))
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +47,7 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
|
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-Methods", "GET, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Bridge-Token, X-XWorkspace-Token")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@ -62,7 +65,14 @@ func allowedOrigin(origin string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, AuthStatusResponse{Required: s.auth.Required()})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
services := s.serviceProbe.Probe()
|
services := s.serviceProbe.Probe()
|
||||||
writeJSON(w, HealthResponse{
|
writeJSON(w, HealthResponse{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
@ -77,13 +87,36 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, s.serviceProbe.Probe())
|
writeJSON(w, s.serviceProbe.Probe())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) portalServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, PortalServicesResponse{Services: s.portalServices.Services()})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n"))
|
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if s.auth.Authorize(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
|
|||||||
21
api/types.go
21
api/types.go
@ -9,6 +9,27 @@ type Service struct {
|
|||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PortalService struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
OpenMode string `json:"openMode"`
|
||||||
|
HealthURL string `json:"healthUrl,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Match []string `json:"match,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortalServicesResponse struct {
|
||||||
|
Services []PortalService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthStatusResponse struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type HealthResponse struct {
|
type HealthResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
|
|||||||
@ -6,7 +6,9 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=%h/xworkspace-console/api
|
WorkingDirectory=%h/xworkspace-console/api
|
||||||
ExecStart=/usr/local/go/bin/go run .
|
EnvironmentFile=-%h/.config/xworkspace/portal.env
|
||||||
|
Environment=XWORKSPACE_PORTAL_SERVICES_FILE=%h/.config/xworkspace/portal-services.json
|
||||||
|
ExecStart=/usr/bin/env go run .
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh } from '@/lib/data';
|
import {
|
||||||
import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data';
|
buildInitialTabs,
|
||||||
import { fetchDashboardStatus } from '@/lib/api';
|
buildNavSections,
|
||||||
|
customWorkspaceTabs,
|
||||||
|
fallbackMetrics,
|
||||||
|
findPortalService,
|
||||||
|
initialTabs,
|
||||||
|
labelsEn,
|
||||||
|
labelsZh,
|
||||||
|
portalServices,
|
||||||
|
portalServiceToTab,
|
||||||
|
} from '@/lib/data';
|
||||||
|
import type { NavItem, PortalService, RuntimeMetrics, Service, Tab } from '@/lib/data';
|
||||||
|
import { fetchAuthStatus, fetchDashboardStatus, fetchPortalServices, validateBridgeToken } 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';
|
||||||
import { WorkspaceHome } from './WorkspaceHome';
|
import { WorkspaceHome } from './WorkspaceHome';
|
||||||
import { EmbedView } from './EmbedView';
|
import { ServicePanel } from './ServicePanel';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||||
@ -19,14 +30,33 @@ export function AppShell() {
|
|||||||
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);
|
||||||
|
const [portalServicesConfig, setPortalServicesConfig] = useState<PortalService[]>(portalServices);
|
||||||
|
const [authRequired, setAuthRequired] = useState(false);
|
||||||
|
const [authStatusLoaded, setAuthStatusLoaded] = useState(false);
|
||||||
|
const [authToken, setAuthToken] = useState('');
|
||||||
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
|
const [tokenLoaded, setTokenLoaded] = useState(false);
|
||||||
|
const [authError, setAuthError] = useState(false);
|
||||||
|
const [authChecking, setAuthChecking] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchDashboardStatus().then((data) => {
|
if (!tokenLoaded || !authStatusLoaded || (authRequired && !authToken)) return;
|
||||||
if (!active || !data) return;
|
Promise.all([fetchDashboardStatus(authToken), fetchPortalServices(authToken)]).then(([statusResult, portalResult]) => {
|
||||||
setServices(data.services);
|
if (!active) return;
|
||||||
setMetrics(data.metrics);
|
if (statusResult.unauthorized || portalResult.unauthorized) {
|
||||||
|
setAuthRequired(true);
|
||||||
|
setAuthError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusResult.data) {
|
||||||
|
setServices(statusResult.data.services);
|
||||||
|
setMetrics(statusResult.data.metrics);
|
||||||
|
}
|
||||||
|
if (portalResult.data?.length) {
|
||||||
|
setPortalServicesConfig(portalResult.data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
refresh();
|
refresh();
|
||||||
@ -35,6 +65,29 @@ export function AppShell() {
|
|||||||
active = false;
|
active = false;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
|
}, [authRequired, authStatusLoaded, authToken, tokenLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabs((existingTabs) => {
|
||||||
|
const workspaceTab = existingTabs.find((tab) => tab.id === 'workspace') ?? buildInitialTabs([])[0];
|
||||||
|
const customTabs = existingTabs.filter((tab) => tab.id !== 'workspace' && !tab.serviceKey);
|
||||||
|
return [workspaceTab, ...portalServicesConfig.map(portalServiceToTab), ...customTabs];
|
||||||
|
});
|
||||||
|
const activeServiceKey = selectedTab.startsWith('service-') ? selectedTab.replace(/^service-/, '') : undefined;
|
||||||
|
if (activeServiceKey && !findPortalService(activeServiceKey, portalServicesConfig)) {
|
||||||
|
setSelectedTab('workspace');
|
||||||
|
}
|
||||||
|
}, [portalServicesConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedToken = window.localStorage.getItem('xworkspace-bridge-token') ?? '';
|
||||||
|
setAuthToken(storedToken);
|
||||||
|
setTokenInput(storedToken);
|
||||||
|
setTokenLoaded(true);
|
||||||
|
fetchAuthStatus().then((status) => {
|
||||||
|
if (status?.required) setAuthRequired(true);
|
||||||
|
setAuthStatusLoaded(true);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -71,7 +124,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
const currentServices = services ?? [];
|
const currentServices = services ?? [];
|
||||||
const selected = tabs.find((tab) => tab.id === selectedTab);
|
const selected = tabs.find((tab) => tab.id === selectedTab);
|
||||||
|
const selectedService = findPortalService(selected?.serviceKey, portalServicesConfig);
|
||||||
const labels = language === 'zh' ? labelsZh : labelsEn;
|
const labels = language === 'zh' ? labelsZh : labelsEn;
|
||||||
|
const dynamicNavSections = useMemo(() => buildNavSections(portalServicesConfig), [portalServicesConfig]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
||||||
@ -79,11 +134,15 @@ export function AppShell() {
|
|||||||
}, [currentServices, metrics]);
|
}, [currentServices, metrics]);
|
||||||
|
|
||||||
const openTab = (item: NavItem | Tab) => {
|
const openTab = (item: NavItem | Tab) => {
|
||||||
|
const service = findPortalService(item.serviceKey, portalServicesConfig);
|
||||||
|
const nextItem = service ? portalServiceToTab(service) : item;
|
||||||
setTabs((existingTabs) => {
|
setTabs((existingTabs) => {
|
||||||
if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs;
|
if (existingTabs.some((tab) => tab.id === nextItem.id)) {
|
||||||
return [...existingTabs, { ...item, closable: true }];
|
return existingTabs.map((tab) => (tab.id === nextItem.id ? { ...tab, ...nextItem, closable: tab.closable ?? true } : tab));
|
||||||
|
}
|
||||||
|
return [...existingTabs, { ...nextItem, closable: true }];
|
||||||
});
|
});
|
||||||
setSelectedTab(item.id);
|
setSelectedTab(nextItem.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeTab = (tabId: string) => {
|
const closeTab = (tabId: string) => {
|
||||||
@ -99,10 +158,35 @@ export function AppShell() {
|
|||||||
openTab(nextTab);
|
openTab(nextTab);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitToken = async () => {
|
||||||
|
const nextToken = tokenInput.trim();
|
||||||
|
if (!nextToken) return;
|
||||||
|
setAuthChecking(true);
|
||||||
|
setAuthError(false);
|
||||||
|
|
||||||
|
const portalResult = await validateBridgeToken(nextToken);
|
||||||
|
setAuthChecking(false);
|
||||||
|
|
||||||
|
if (portalResult.unauthorized || !portalResult.data?.length) {
|
||||||
|
setAuthError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem('xworkspace-bridge-token', nextToken);
|
||||||
|
setPortalServicesConfig(portalResult.data);
|
||||||
|
setAuthToken(nextToken);
|
||||||
|
setTabs(buildInitialTabs(portalResult.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokenLoaded && authStatusLoaded && authRequired && (!authToken || authError)) {
|
||||||
|
return <AuthGate token={tokenInput} error={authError} checking={authChecking} onTokenChange={setTokenInput} onSubmit={submitToken} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}>
|
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
labels={labels}
|
labels={labels}
|
||||||
|
navSections={dynamicNavSections}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
selectedTab={selectedTab}
|
selectedTab={selectedTab}
|
||||||
onToggle={() => setSidebarCollapsed((value) => !value)}
|
onToggle={() => setSidebarCollapsed((value) => !value)}
|
||||||
@ -126,12 +210,53 @@ export function AppShell() {
|
|||||||
|
|
||||||
<WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} />
|
<WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} />
|
||||||
|
|
||||||
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
{selected?.kind === 'embed' && selectedService ? (
|
||||||
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
|
<ServicePanel service={selectedService} onBack={() => setSelectedTab('workspace')} />
|
||||||
) : (
|
) : (
|
||||||
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} onOpenService={openTab} />
|
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} portalServices={portalServicesConfig} onOpenService={openTab} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AuthGate({
|
||||||
|
token,
|
||||||
|
error,
|
||||||
|
checking,
|
||||||
|
onTokenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
error: boolean;
|
||||||
|
checking: boolean;
|
||||||
|
onTokenChange: (value: string) => void;
|
||||||
|
onSubmit: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="auth-gate">
|
||||||
|
<form
|
||||||
|
className="auth-card"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="brand-mark">AI</span>
|
||||||
|
<h1>AI Workspace Portal</h1>
|
||||||
|
<p>Enter the xworkmate-bridge token to load local services.</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => onTokenChange(event.target.value)}
|
||||||
|
placeholder="Bridge token"
|
||||||
|
aria-label="Bridge token"
|
||||||
|
disabled={checking}
|
||||||
|
/>
|
||||||
|
{error ? <small>Token rejected by the local API.</small> : null}
|
||||||
|
<button type="submit" disabled={checking}>{checking ? 'Unlocking...' : 'Unlock'}</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,27 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
|
import { acpAgents, findPortalService, findPortalServiceByRole, findPortalServiceStatus, portalServiceToNavItem, skillGroups } from '@/lib/data';
|
||||||
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
|
import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export function ArchPipeline({
|
export function ArchPipeline({
|
||||||
labels,
|
labels,
|
||||||
services,
|
services,
|
||||||
metrics,
|
metrics,
|
||||||
|
portalServices,
|
||||||
onOpenService,
|
onOpenService,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
metrics: RuntimeMetrics;
|
metrics: RuntimeMetrics;
|
||||||
|
portalServices: PortalService[];
|
||||||
onOpenService: (item: NavItem) => void;
|
onOpenService: (item: NavItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||||
|
const gatewayService = findPortalServiceByRole('gateway', portalServices);
|
||||||
|
const modelRouterService = findPortalServiceByRole('model-router', portalServices);
|
||||||
|
|
||||||
const stateOf = (id: string): Service['state'] | undefined => {
|
const openPortalService = (serviceKey?: string) => {
|
||||||
const def = serviceRegistry.find((item) => item.id === id);
|
const serviceConfig = findPortalService(serviceKey, portalServices);
|
||||||
const service = services.find((entry) => def?.match?.some((token) => entry.name.toLowerCase().includes(token)));
|
if (serviceConfig) onOpenService(portalServiceToNavItem(serviceConfig));
|
||||||
return service?.state;
|
|
||||||
};
|
};
|
||||||
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'} />
|
||||||
@ -48,16 +51,15 @@ export function ArchPipeline({
|
|||||||
<small>APP Chat / Web Chat<br />XWorkmate Bridge</small>
|
<small>APP Chat / Web Chat<br />XWorkmate Bridge</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="gateway-card node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'openclaw')!)}>
|
<button type="button" className="gateway-card node-card" onClick={() => openPortalService(gatewayService?.key)}>
|
||||||
<div className="gateway-meta">
|
<div className="gateway-meta">
|
||||||
<span className="node-index blue">1</span>
|
<span className="node-index blue">1</span>
|
||||||
<span className="node-title">{labels.gatewayBand}</span>
|
<span className="node-title">{labels.gatewayBand}</span>
|
||||||
</div>
|
</div>
|
||||||
{dot(stateOf('openclaw'))}
|
{dot(findPortalServiceStatus(services, gatewayService?.key, portalServices))}
|
||||||
<strong>OpenClaw Gateway</strong>
|
<strong>{gatewayService?.name ?? labels.gatewayBand}</strong>
|
||||||
<small>v2026.6.1</small>
|
<small>{gatewayService?.description ?? 'Gateway service'}</small>
|
||||||
<small>127.0.0.1:18789</small>
|
<small>{gatewayService?.url ?? 'Not configured'}</small>
|
||||||
<small>token auth</small>
|
|
||||||
<small>Local Only</small>
|
<small>Local Only</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +100,7 @@ export function ArchPipeline({
|
|||||||
<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}+ {labels.skillsCount}</small>
|
<small>{totalSkills}+ {labels.skillsCount}</small>
|
||||||
{dot(stateOf('bridge'))}
|
{dot()}
|
||||||
</div>
|
</div>
|
||||||
<div className="skill-stack">
|
<div className="skill-stack">
|
||||||
{skillGroups.map((group) => (
|
{skillGroups.map((group) => (
|
||||||
@ -119,12 +121,12 @@ export function ArchPipeline({
|
|||||||
<div><span>Skills</span><b>{totalSkills}+</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={() => openPortalService(modelRouterService?.key)}>
|
||||||
<div className="node-head">
|
<div className="node-head">
|
||||||
<span className="node-index amber">4</span>
|
<span className="node-index amber">4</span>
|
||||||
<strong>{labels.modelBand} Layer</strong>
|
<strong>{labels.modelBand} Layer</strong>
|
||||||
<small>LiteLLM · 4000 · OpenAI-compatible · Anthropic-compatible</small>
|
<small>{modelRouterService ? `${modelRouterService.name} · ${modelRouterService.url}` : 'Not configured'}</small>
|
||||||
{dot(stateOf('litellm'))}
|
{dot(findPortalServiceStatus(services, modelRouterService?.key, portalServices))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -145,5 +147,3 @@ export function ArchPipeline({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { findServiceDef };
|
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { Tab } from '@/lib/data';
|
|
||||||
import { Icon } from './Icon';
|
|
||||||
|
|
||||||
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
|
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
|
||||||
const frameBlocked = tab.frameMode === 'external';
|
|
||||||
return (
|
|
||||||
<div className="workspace-body">
|
|
||||||
<section className="embed-panel">
|
|
||||||
<div className="embed-toolbar">
|
|
||||||
<button type="button" className="embed-tool" aria-label="Back to workspace" onClick={onBack}>
|
|
||||||
<Icon name="arrow-left" />
|
|
||||||
</button>
|
|
||||||
<strong>{tab.label}</strong>
|
|
||||||
<span className="embed-url" title={tab.href}>{tab.href}</span>
|
|
||||||
<div className="embed-toolbar-actions">
|
|
||||||
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={frameBlocked}>
|
|
||||||
<Icon name="refresh" />
|
|
||||||
</button>
|
|
||||||
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
|
|
||||||
<Icon name="external" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{frameBlocked ? (
|
|
||||||
<div className="external-embed-fallback">
|
|
||||||
<div>
|
|
||||||
<span className="external-embed-icon"><Icon name={tab.icon ?? 'external'} /></span>
|
|
||||||
<strong>{tab.label}</strong>
|
|
||||||
<p>This service blocks embedded frames. Open it in a dedicated browser tab.</p>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { findServiceDef } from '@/lib/data';
|
import { findPortalServiceForStatus } from '@/lib/data';
|
||||||
import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
|
import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
@ -17,14 +17,14 @@ export function PanelsRow({ labels, services, metrics }: { labels: Labels; servi
|
|||||||
</div>
|
</div>
|
||||||
<div className="health-row">
|
<div className="health-row">
|
||||||
{services.map((service) => {
|
{services.map((service) => {
|
||||||
const def = findServiceDef(service.name);
|
const portalService = findPortalServiceForStatus(service.name);
|
||||||
const running = service.state === 'Running';
|
const running = service.state === 'Running';
|
||||||
return (
|
return (
|
||||||
<div className="health-item" key={service.name} title={service.name}>
|
<div className="health-item" key={service.name} title={service.name}>
|
||||||
<span className={running ? 'health-icon good' : 'health-icon bad'}>
|
<span className={running ? 'health-icon good' : 'health-icon bad'}>
|
||||||
<Icon name={def?.icon ?? 'cube'} />
|
<Icon name={portalService?.icon ?? 'cube'} />
|
||||||
</span>
|
</span>
|
||||||
<small>{def?.label ?? service.name}</small>
|
<small>{portalService?.name ?? service.name}</small>
|
||||||
<em>{running ? labels.healthy : labels.degraded}</em>
|
<em>{running ? labels.healthy : labels.degraded}</em>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
65
dashboard/src/components/ServicePanel.tsx
Normal file
65
dashboard/src/components/ServicePanel.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { PortalService } from '@/lib/data';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export function ServicePanel({ service, onBack }: { service: PortalService; onBack: () => void }) {
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const external = service.openMode === 'external';
|
||||||
|
const focusFrame = () => {
|
||||||
|
iframeRef.current?.focus();
|
||||||
|
iframeRef.current?.contentWindow?.focus();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="workspace-body">
|
||||||
|
<section className="embed-panel">
|
||||||
|
<div className="embed-toolbar">
|
||||||
|
<button type="button" className="embed-tool" aria-label="Back to workspace" onClick={onBack}>
|
||||||
|
<Icon name="arrow-left" />
|
||||||
|
</button>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<span className="embed-url" title={service.url}>{service.url}</span>
|
||||||
|
<div className="embed-toolbar-actions">
|
||||||
|
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={external}>
|
||||||
|
<Icon name="refresh" />
|
||||||
|
</button>
|
||||||
|
<a className="embed-tool" href={service.url} target="_blank" rel="noreferrer" aria-label="Open in browser">
|
||||||
|
<Icon name="external" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{external ? (
|
||||||
|
<div className="external-embed-fallback">
|
||||||
|
<div>
|
||||||
|
<span className="external-embed-icon"><Icon name={service.icon ?? 'external'} /></span>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<p>{service.description ?? 'Open this service in a dedicated browser tab.'}</p>
|
||||||
|
<a href={service.url} target="_blank" rel="noreferrer">
|
||||||
|
<Icon name="external" />
|
||||||
|
<span>Open {service.name}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
key={reloadKey}
|
||||||
|
title={`${service.name} workspace`}
|
||||||
|
src={service.url}
|
||||||
|
tabIndex={0}
|
||||||
|
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
onLoad={focusFrame}
|
||||||
|
onFocus={focusFrame}
|
||||||
|
onPointerDown={focusFrame}
|
||||||
|
onMouseDown={focusFrame}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { navSections } from '@/lib/data';
|
import type { Labels, NavItem, NavSection } from '@/lib/data';
|
||||||
import type { Labels, NavItem } from '@/lib/data';
|
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
labels,
|
labels,
|
||||||
|
navSections,
|
||||||
collapsed,
|
collapsed,
|
||||||
selectedTab,
|
selectedTab,
|
||||||
onToggle,
|
onToggle,
|
||||||
@ -18,6 +18,7 @@ export function Sidebar({
|
|||||||
onToggleRemoteMode,
|
onToggleRemoteMode,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
|
navSections: NavSection[];
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
selectedTab: string;
|
selectedTab: string;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export function TerminalDrawer({
|
|||||||
const [height, setHeight] = useState(250);
|
const [height, setHeight] = useState(250);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const dragStart = useRef({ y: 0, height: 250 });
|
const dragStart = useRef({ y: 0, height: 250 });
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const onDragStart = (event: React.PointerEvent) => {
|
const onDragStart = (event: React.PointerEvent) => {
|
||||||
if (expanded || collapsed) return;
|
if (expanded || collapsed) return;
|
||||||
@ -42,6 +43,11 @@ export function TerminalDrawer({
|
|||||||
setDragging(false);
|
setDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusTerminal = () => {
|
||||||
|
iframeRef.current?.focus();
|
||||||
|
iframeRef.current?.contentWindow?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}>
|
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}>
|
||||||
<div
|
<div
|
||||||
@ -71,7 +77,18 @@ export function TerminalDrawer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}>
|
<div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}>
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<iframe title="ttyd terminal" src="http://127.0.0.1:7681" loading="lazy" style={dragging ? { pointerEvents: 'none' } : undefined} />
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title="ttyd terminal"
|
||||||
|
src="http://127.0.0.1:7681"
|
||||||
|
loading="lazy"
|
||||||
|
tabIndex={0}
|
||||||
|
onLoad={focusTerminal}
|
||||||
|
onFocus={focusTerminal}
|
||||||
|
onPointerDown={focusTerminal}
|
||||||
|
onMouseDown={focusTerminal}
|
||||||
|
style={dragging ? { pointerEvents: 'none' } : undefined}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
|
import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data';
|
||||||
import { ArchPipeline } from './ArchPipeline';
|
import { ArchPipeline } from './ArchPipeline';
|
||||||
|
|
||||||
export function WorkspaceHome({
|
export function WorkspaceHome({
|
||||||
labels,
|
labels,
|
||||||
services,
|
services,
|
||||||
metrics,
|
metrics,
|
||||||
|
portalServices,
|
||||||
onOpenService,
|
onOpenService,
|
||||||
}: {
|
}: {
|
||||||
labels: Labels;
|
labels: Labels;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
metrics: RuntimeMetrics;
|
metrics: RuntimeMetrics;
|
||||||
|
portalServices: PortalService[];
|
||||||
onOpenService: (item: NavItem) => void;
|
onOpenService: (item: NavItem) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -23,7 +25,7 @@ export function WorkspaceHome({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArchPipeline labels={labels} services={services} metrics={metrics} onOpenService={onOpenService} />
|
<ArchPipeline labels={labels} services={services} metrics={metrics} portalServices={portalServices} onOpenService={onOpenService} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
import type { DashboardStatus, Service } from './data';
|
import type { DashboardStatus, PortalService, Service } from './data';
|
||||||
|
|
||||||
const BASE = 'http://127.0.0.1:8788';
|
const BASE = 'http://127.0.0.1:8788';
|
||||||
|
|
||||||
|
export type ApiResult<T> = {
|
||||||
|
data: T | null;
|
||||||
|
unauthorized: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authHeaders = (token?: string): HeadersInit => {
|
||||||
|
const trimmed = token?.trim();
|
||||||
|
return trimmed ? { Authorization: `Bearer ${trimmed}`, 'X-Bridge-Token': trimmed } : {};
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeServiceState = (state?: string): Service['state'] => {
|
const normalizeServiceState = (state?: string): Service['state'] => {
|
||||||
if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
|
if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
|
||||||
if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped';
|
if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped';
|
||||||
@ -17,35 +27,77 @@ const mapService = (item: { name?: string; unit?: string; state?: string; detail
|
|||||||
state: normalizeServiceState(item.state),
|
state: normalizeServiceState(item.state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function fetchDashboardStatus(): Promise<DashboardStatus | null> {
|
export async function fetchAuthStatus(): Promise<{ required: boolean } | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE}/health`, { cache: 'no-store' });
|
const response = await fetch(`${BASE}/auth/status`, { 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.services)) return null;
|
return { required: Boolean(data.required) };
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchServices(): Promise<Service[] | null> {
|
export async function fetchDashboardStatus(token?: string): Promise<ApiResult<DashboardStatus>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE}/services`, { cache: 'no-store' });
|
const response = await fetch(`${BASE}/health`, { cache: 'no-store', headers: authHeaders(token) });
|
||||||
if (!response.ok) return null;
|
if (response.status === 401) return { data: null, unauthorized: true };
|
||||||
|
if (!response.ok) return { data: null, unauthorized: false };
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!Array.isArray(data)) return null;
|
if (!Array.isArray(data.services)) return { data: null, unauthorized: false };
|
||||||
return data.map(mapService);
|
return {
|
||||||
|
data: {
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unauthorized: false,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { data: null, unauthorized: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchServices(token?: string): Promise<ApiResult<Service[]>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE}/services`, { cache: 'no-store', headers: authHeaders(token) });
|
||||||
|
if (response.status === 401) return { data: null, unauthorized: true };
|
||||||
|
if (!response.ok) return { data: null, unauthorized: false };
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return { data: null, unauthorized: false };
|
||||||
|
return { data: data.map(mapService), unauthorized: false };
|
||||||
|
} catch {
|
||||||
|
return { data: null, unauthorized: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortalServices(token?: string): Promise<ApiResult<PortalService[]>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE}/portal/services`, { cache: 'no-store', headers: authHeaders(token) });
|
||||||
|
if (response.status === 401) return { data: null, unauthorized: true };
|
||||||
|
if (!response.ok) return { data: null, unauthorized: false };
|
||||||
|
const data = await response.json();
|
||||||
|
const services = Array.isArray(data.services) ? data.services : Array.isArray(data) ? data : null;
|
||||||
|
if (!services) return { data: null, unauthorized: false };
|
||||||
|
return {
|
||||||
|
data: services
|
||||||
|
.filter((service: Partial<PortalService>) => service.key && service.name && service.url)
|
||||||
|
.map((service: PortalService) => ({
|
||||||
|
...service,
|
||||||
|
openMode: service.openMode === 'external' ? 'external' : 'iframe',
|
||||||
|
})),
|
||||||
|
unauthorized: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { data: null, unauthorized: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateBridgeToken(token: string): Promise<ApiResult<PortalService[]>> {
|
||||||
|
return fetchPortalServices(token);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,20 @@ export type Tab = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
source?: 'builtin' | 'custom';
|
source?: 'builtin' | 'custom';
|
||||||
frameMode?: 'iframe' | 'external';
|
serviceKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalService = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
openMode: 'iframe' | 'external';
|
||||||
|
healthUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
match?: string[];
|
||||||
|
port?: number;
|
||||||
|
role?: 'gateway' | 'model-router';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Service = {
|
export type Service = {
|
||||||
@ -37,47 +50,135 @@ export type NavItem = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
href: string;
|
href: string;
|
||||||
kind: Tab['kind'];
|
kind: Tab['kind'];
|
||||||
|
serviceKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceDef = NavItem & {
|
export type NavSectionItem = NavItem & {
|
||||||
group: number;
|
group: number;
|
||||||
port?: number;
|
port?: number;
|
||||||
match?: string[];
|
match?: string[];
|
||||||
frameMode?: Tab['frameMode'];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serviceRegistry: ServiceDef[] = [
|
export type NavSection = { id: string; titleKey: string; items: NavSectionItem[] };
|
||||||
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
|
|
||||||
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:8788/proxy/openclaw/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'] },
|
export const portalServices: PortalService[] = [
|
||||||
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://127.0.0.1:8788/proxy/vault/ui/', kind: 'embed', group: 1, port: 8200, match: ['vault'] },
|
{
|
||||||
{ id: 'litellm', label: 'LiteLLM Admin UI', icon: 'chart', href: 'http://localhost:4000/ui', kind: 'embed', group: 1, port: 4000, match: ['litellm', 'lite'] },
|
key: 'litellm',
|
||||||
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] },
|
name: 'LiteLLM Admin UI',
|
||||||
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 },
|
url: 'http://localhost:4000/ui',
|
||||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal', href: 'http://127.0.0.1:7681', kind: 'embed', group: 2, port: 7681 },
|
openMode: 'iframe',
|
||||||
|
description: 'Model routing and provider administration.',
|
||||||
|
icon: 'chart',
|
||||||
|
match: ['litellm', 'lite'],
|
||||||
|
port: 4000,
|
||||||
|
role: 'model-router',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'openclaw',
|
||||||
|
name: 'OpenClaw',
|
||||||
|
url: 'http://127.0.0.1:18789/channels',
|
||||||
|
openMode: 'external',
|
||||||
|
description: 'Gateway dashboard. Opens outside the portal because the service blocks embedded frames.',
|
||||||
|
icon: 'claw',
|
||||||
|
match: ['openclaw', 'gateway'],
|
||||||
|
port: 18789,
|
||||||
|
role: 'gateway',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vault',
|
||||||
|
name: 'Vault Server',
|
||||||
|
url: 'http://127.0.0.1:8200/ui',
|
||||||
|
openMode: 'external',
|
||||||
|
description: 'Vault UI. Opens outside the portal because the service blocks embedded frames.',
|
||||||
|
icon: 'shield',
|
||||||
|
match: ['vault'],
|
||||||
|
port: 8200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'terminal',
|
||||||
|
name: 'Terminal',
|
||||||
|
url: 'http://127.0.0.1:7681',
|
||||||
|
openMode: 'iframe',
|
||||||
|
healthUrl: 'http://127.0.0.1:7681',
|
||||||
|
description: 'Local ttyd terminal.',
|
||||||
|
icon: 'terminal',
|
||||||
|
match: ['ttyd', 'terminal'],
|
||||||
|
port: 7681,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const navSections: { id: string; titleKey: string; items: ServiceDef[] }[] = [
|
export const workspaceNavItems: NavSectionItem[] = [
|
||||||
|
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
|
||||||
|
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const portalServiceToNavItem = (service: PortalService): NavSectionItem => ({
|
||||||
|
id: `service-${service.key}`,
|
||||||
|
label: service.name,
|
||||||
|
icon: service.icon ?? 'cube',
|
||||||
|
href: service.url,
|
||||||
|
kind: 'embed',
|
||||||
|
group: 1,
|
||||||
|
port: service.port,
|
||||||
|
match: service.match,
|
||||||
|
serviceKey: service.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portalServiceToTab = (service: PortalService): Tab => ({
|
||||||
|
id: `service-${service.key}`,
|
||||||
|
label: service.name,
|
||||||
|
href: service.url,
|
||||||
|
kind: 'embed',
|
||||||
|
icon: service.icon ?? 'cube',
|
||||||
|
closable: true,
|
||||||
|
source: 'builtin',
|
||||||
|
serviceKey: service.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portalNavItems = portalServices.map(portalServiceToNavItem);
|
||||||
|
export const portalTabs = portalServices.map(portalServiceToTab);
|
||||||
|
|
||||||
|
export const buildNavSections = (services: PortalService[] = portalServices): NavSection[] => [
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
titleKey: '',
|
titleKey: '',
|
||||||
items: [
|
items: workspaceNavItems,
|
||||||
serviceRegistry.find((item) => item.id === 'workspace')!,
|
|
||||||
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'services',
|
id: 'services',
|
||||||
titleKey: 'navServices',
|
titleKey: 'navServices',
|
||||||
items: [
|
items: services.map(portalServiceToNavItem),
|
||||||
...serviceRegistry.filter((item) => item.group === 1),
|
|
||||||
serviceRegistry.find((item) => item.id === 'terminal')!,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const findServiceDef = (serviceName: string): ServiceDef | undefined => {
|
export const navSections = buildNavSections();
|
||||||
|
|
||||||
|
export const findPortalService = (serviceKey?: string, services: PortalService[] = portalServices): PortalService | undefined => {
|
||||||
|
if (!serviceKey) return undefined;
|
||||||
|
return services.find((service) => service.key === serviceKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPortalServiceByRole = (role: PortalService['role'], services: PortalService[] = portalServices): PortalService | undefined => {
|
||||||
|
if (!role) return undefined;
|
||||||
|
return services.find((service) => service.role === role);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPortalServiceForStatus = (serviceName: string, services: PortalService[] = portalServices): PortalService | undefined => {
|
||||||
const name = serviceName.toLowerCase();
|
const name = serviceName.toLowerCase();
|
||||||
return serviceRegistry.find((def) => def.match?.some((token) => name.includes(token)));
|
return services.find((service) => {
|
||||||
|
const tokens = service.match ?? [service.key, service.name];
|
||||||
|
return tokens.some((token) => name.includes(token.toLowerCase()));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPortalServiceStatus = (
|
||||||
|
services: Service[],
|
||||||
|
serviceKey?: string,
|
||||||
|
portalServiceConfig: PortalService[] = portalServices,
|
||||||
|
): Service['state'] | undefined => {
|
||||||
|
const serviceConfig = findPortalService(serviceKey, portalServiceConfig);
|
||||||
|
if (!serviceConfig) return undefined;
|
||||||
|
return services.find((entry) => findPortalServiceForStatus(entry.name, portalServiceConfig)?.key === serviceConfig.key)?.state;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const customWorkspaceTabs: Tab[] = [
|
export const customWorkspaceTabs: Tab[] = [
|
||||||
@ -85,10 +186,13 @@ export const customWorkspaceTabs: Tab[] = [
|
|||||||
{ id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' },
|
{ id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const initialTabs: Tab[] = [
|
export const buildInitialTabs = (services: PortalService[] = portalServices): Tab[] => [
|
||||||
{ id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' },
|
{ id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' },
|
||||||
|
...services.map(portalServiceToTab),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const initialTabs = buildInitialTabs();
|
||||||
|
|
||||||
export const fallbackMetrics: RuntimeMetrics = {
|
export const fallbackMetrics: RuntimeMetrics = {
|
||||||
activeSessions: 0,
|
activeSessions: 0,
|
||||||
connectedAgents: 0,
|
connectedAgents: 0,
|
||||||
|
|||||||
@ -40,6 +40,72 @@ button {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-gate {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
background: #edf2fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .brand-mark {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f7df3, #5b4fe3);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card p,
|
||||||
|
.auth-card small {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card small {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button {
|
||||||
|
height: 46px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--blue);
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -934,7 +1000,7 @@ button {
|
|||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-embed-fallback > div {
|
.external-embed-fallback > div {
|
||||||
@ -952,7 +1018,7 @@ button {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--soft);
|
background: var(--soft);
|
||||||
color: var(--accent);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-embed-icon .icon {
|
.external-embed-icon .icon {
|
||||||
@ -975,15 +1041,21 @@ button {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
gap: 8px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--text);
|
background: var(--text);
|
||||||
color: var(--panel);
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-embed-fallback a .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-drawer {
|
.terminal-drawer {
|
||||||
margin: 18px 22px 28px;
|
margin: 18px 22px 28px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# AI Workspace All-in-One Bootstrap Script
|
# AI Workspace All-in-One Bootstrap Script
|
||||||
@ -15,46 +15,747 @@ set -e
|
|||||||
# GATEWAY_OPENCLAW_PUBLIC_ACCESS
|
# GATEWAY_OPENCLAW_PUBLIC_ACCESS
|
||||||
# VAULT_PUBLIC_ACCESS
|
# VAULT_PUBLIC_ACCESS
|
||||||
# XWORKSPACE_CONSOLE_ENABLE_XRDP
|
# XWORKSPACE_CONSOLE_ENABLE_XRDP
|
||||||
# VAULT_PASS (Will be securely passed as vault password if set)
|
# AI_WORKSPACE_AUTH_TOKEN / XWORKSPACE_CONSOLE_AUTH_TOKEN
|
||||||
|
# / XWORKMATE_BRIDGE_AUTH_TOKEN / BRIDGE_AUTH_TOKEN / INTERNAL_SERVICE_TOKEN
|
||||||
|
# / DEPLOY_TOKEN
|
||||||
|
# Unified auth token passed to xworkmate-bridge, LiteLLM, OpenClaw, and Vault.
|
||||||
|
# PLAYBOOK_DIR (optional local playbooks checkout; useful for macOS validation)
|
||||||
|
# XWORKSPACE_CONSOLE_DIR (optional local xworkspace-console checkout for macOS)
|
||||||
|
# AI_WORKSPACE_DARWIN_MODE=local (default on macOS) | ansible
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
REPO_URL=${REPO_URL:-"https://github.com/ai-workspace-infra/playbooks.git"}
|
REPO_URL=${REPO_URL:-"https://github.com/ai-workspace-infra/playbooks.git"}
|
||||||
BRANCH=${BRANCH:-"main"}
|
BRANCH=${BRANCH:-"main"}
|
||||||
TARGET_DIR="/tmp/ai-workspace-deploy"
|
TARGET_DIR=${TARGET_DIR:-"/tmp/ai-workspace-deploy"}
|
||||||
|
PLAYBOOK_DIR=${PLAYBOOK_DIR:-""}
|
||||||
|
XWORKSPACE_CONSOLE_REPO_URL=${XWORKSPACE_CONSOLE_REPO_URL:-"https://github.com/ai-workspace-lab/xworkspace-console.git"}
|
||||||
|
XWORKSPACE_CONSOLE_DIR=${XWORKSPACE_CONSOLE_DIR:-""}
|
||||||
|
AUTH_TOKEN_FILE=${AI_WORKSPACE_AUTH_TOKEN_FILE:-"$HOME/.ai_workspace_auth_token"}
|
||||||
|
VAULT_FILE=${AI_WORKSPACE_VAULT_PASSWORD_FILE:-"$HOME/.vault_password"}
|
||||||
|
|
||||||
# Function: Output messages
|
# Function: Output messages
|
||||||
info() {
|
info() {
|
||||||
echo -e "\033[1;34m[INFO]\033[0m $*"
|
echo -e "\033[1;34m[INFO]\033[0m $*" >&2
|
||||||
}
|
}
|
||||||
success() {
|
success() {
|
||||||
echo -e "\033[1;32m[SUCCESS]\033[0m $*"
|
echo -e "\033[1;32m[SUCCESS]\033[0m $*" >&2
|
||||||
}
|
}
|
||||||
error() {
|
error() {
|
||||||
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
info "Starting AI Workspace All-in-One Bootstrap..."
|
mask_secret() {
|
||||||
|
local val="${1:-}"
|
||||||
|
if [ -z "$val" ]; then
|
||||||
|
echo "<empty>"
|
||||||
|
elif [ "${#val}" -le 8 ]; then
|
||||||
|
echo "<hidden>"
|
||||||
|
else
|
||||||
|
echo "${val:0:4}...${val: -4}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 1. Install prerequisites (git, curl, ansible) if missing
|
detect_os() {
|
||||||
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
|
case "$(uname -s)" in
|
||||||
|
Darwin) echo "darwin" ;;
|
||||||
|
Linux) echo "linux" ;;
|
||||||
|
*) echo "unknown" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
install_prerequisites() {
|
||||||
|
local os="$1"
|
||||||
info "Installing required dependencies (git, ansible)..."
|
info "Installing required dependencies (git, ansible)..."
|
||||||
if [ -f /etc/debian_version ]; then
|
if [ "$os" = "linux" ]; then
|
||||||
sudo apt-get update -y
|
if [ -f /etc/debian_version ]; then
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common
|
sudo apt-get update -y
|
||||||
sudo apt-add-repository --yes --update ppa:ansible/ansible
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common
|
||||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible
|
sudo apt-add-repository --yes --update ppa:ansible/ansible
|
||||||
elif [ -f /etc/redhat-release ]; then
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible
|
||||||
sudo yum install -y epel-release
|
elif [ -f /etc/redhat-release ]; then
|
||||||
sudo yum install -y git curl ansible
|
sudo yum install -y epel-release
|
||||||
|
sudo yum install -y git curl ansible
|
||||||
|
else
|
||||||
|
error "Unsupported Linux distribution. Please install git and ansible manually."
|
||||||
|
fi
|
||||||
|
elif [ "$os" = "darwin" ]; then
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
brew install git ansible
|
||||||
|
else
|
||||||
|
error "macOS requires git and ansible. Install Homebrew or install them manually, then rerun."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
error "Unsupported OS. Please install git and ansible manually."
|
error "Unsupported OS. Please install git and ansible manually."
|
||||||
fi
|
fi
|
||||||
success "Dependencies installed."
|
success "Dependencies installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_unified_auth_token() {
|
||||||
|
local token="${AI_WORKSPACE_AUTH_TOKEN:-}"
|
||||||
|
if [ -z "$token" ]; then token="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-}"; fi
|
||||||
|
if [ -z "$token" ]; then token="${XWORKMATE_BRIDGE_AUTH_TOKEN:-}"; fi
|
||||||
|
if [ -z "$token" ]; then token="${BRIDGE_AUTH_TOKEN:-}"; fi
|
||||||
|
if [ -z "$token" ]; then token="${INTERNAL_SERVICE_TOKEN:-}"; fi
|
||||||
|
if [ -z "$token" ]; then token="${DEPLOY_TOKEN:-}"; fi
|
||||||
|
|
||||||
|
if [ -n "$token" ]; then
|
||||||
|
printf '%s' "$token" > "$AUTH_TOKEN_FILE"
|
||||||
|
chmod 600 "$AUTH_TOKEN_FILE"
|
||||||
|
info "Using provided unified auth token: $(mask_secret "$token")"
|
||||||
|
printf '%s' "$token"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$AUTH_TOKEN_FILE" ]; then
|
||||||
|
info "Found existing unified auth token at $AUTH_TOKEN_FILE, reusing it."
|
||||||
|
tr -d '\r\n' < "$AUTH_TOKEN_FILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "No unified auth token provided. Generating a secure random token..."
|
||||||
|
openssl rand -base64 32 | tr -d '\r\n' > "$AUTH_TOKEN_FILE"
|
||||||
|
chmod 600 "$AUTH_TOKEN_FILE"
|
||||||
|
info "Generated new unified auth token and saved to $AUTH_TOKEN_FILE"
|
||||||
|
cat "$AUTH_TOKEN_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_or_install_macos_cmds() {
|
||||||
|
local missing=()
|
||||||
|
for cmd in git node npm go curl lsof python3; do
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
missing+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "${#missing[@]}" -eq 0 ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! command -v brew >/dev/null 2>&1; then
|
||||||
|
error "Missing required commands on macOS: ${missing[*]}. Install Homebrew or install them manually."
|
||||||
|
fi
|
||||||
|
info "Installing missing macOS dependencies: ${missing[*]}"
|
||||||
|
for cmd in "${missing[@]}"; do
|
||||||
|
case "$cmd" in
|
||||||
|
git) brew install git ;;
|
||||||
|
node|npm) brew install node ;;
|
||||||
|
go) brew install go ;;
|
||||||
|
python3) brew install python@3.13 ;;
|
||||||
|
curl) brew install curl ;;
|
||||||
|
lsof) error "lsof is part of macOS; it is missing from PATH." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
macos_litellm_python() {
|
||||||
|
for py in python3.13 python3.12 python3.11; do
|
||||||
|
if command -v "$py" >/dev/null 2>&1; then
|
||||||
|
command -v "$py"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
info "Installing python@3.13 for LiteLLM compatibility..."
|
||||||
|
brew install python@3.13
|
||||||
|
command -v python3.13
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
error "LiteLLM requires Python 3.11-3.13 on macOS. Install python3.13 or Homebrew."
|
||||||
|
}
|
||||||
|
|
||||||
|
macos_openclaw_bin() {
|
||||||
|
if command -v openclaw >/dev/null 2>&1; then
|
||||||
|
command -v openclaw
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local prefix="$HOME/.local/share/xworkspace/node"
|
||||||
|
info "Installing OpenClaw CLI locally under $prefix..."
|
||||||
|
mkdir -p "$prefix"
|
||||||
|
npm install --prefix "$prefix" openclaw@2026.6.1 @openclaw/codex@2026.6.1
|
||||||
|
printf '%s/bin/openclaw\n' "$prefix"
|
||||||
|
}
|
||||||
|
|
||||||
|
macos_vault_bin() {
|
||||||
|
if command -v vault >/dev/null 2>&1; then
|
||||||
|
command -v vault
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
info "Installing Vault CLI/server with Homebrew..."
|
||||||
|
brew install hashicorp/tap/vault || brew install vault
|
||||||
|
command -v vault
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
error "Vault is required for local macOS deployment. Install vault or Homebrew."
|
||||||
|
}
|
||||||
|
|
||||||
|
macos_ttyd_bin() {
|
||||||
|
if command -v ttyd >/dev/null 2>&1; then
|
||||||
|
command -v ttyd
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
info "Installing ttyd with Homebrew..."
|
||||||
|
brew install ttyd
|
||||||
|
command -v ttyd
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
error "ttyd is required for the local Portal terminal. Install ttyd or Homebrew."
|
||||||
|
}
|
||||||
|
|
||||||
|
macos_postgres_tool() {
|
||||||
|
local tool=$1
|
||||||
|
if command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
command -v "$tool"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql@15/bin /usr/local/opt/postgresql@15/bin /opt/homebrew/opt/postgresql@14/bin /usr/local/opt/postgresql@14/bin /opt/homebrew/bin /usr/local/bin; do
|
||||||
|
if [ -x "$base/$tool" ]; then
|
||||||
|
printf '%s/%s\n' "$base" "$tool"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
info "Installing PostgreSQL for LiteLLM local UI storage..."
|
||||||
|
brew install postgresql@16 || brew install postgresql
|
||||||
|
if command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
command -v "$tool"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql/bin /usr/local/opt/postgresql/bin; do
|
||||||
|
if [ -x "$base/$tool" ]; then
|
||||||
|
printf '%s/%s\n' "$base" "$tool"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
error "PostgreSQL tool '$tool' is required for local LiteLLM UI login."
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_console_dir() {
|
||||||
|
if [ -n "$XWORKSPACE_CONSOLE_DIR" ]; then
|
||||||
|
[ -d "$XWORKSPACE_CONSOLE_DIR/dashboard" ] && [ -d "$XWORKSPACE_CONSOLE_DIR/api" ] || \
|
||||||
|
error "XWORKSPACE_CONSOLE_DIR must contain dashboard/ and api/: $XWORKSPACE_CONSOLE_DIR"
|
||||||
|
cd "$XWORKSPACE_CONSOLE_DIR"
|
||||||
|
pwd
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local script_dir=""
|
||||||
|
if [ -n "${BASH_SOURCE[0]:-}" ]; then
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P || true)"
|
||||||
|
fi
|
||||||
|
if [ -n "$script_dir" ] && [ -d "$script_dir/../dashboard" ] && [ -d "$script_dir/../api" ]; then
|
||||||
|
cd "$script_dir/.."
|
||||||
|
pwd
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -d "$PWD/dashboard" ] && [ -d "$PWD/api" ]; then
|
||||||
|
pwd
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local checkout_dir="${XWORKSPACE_CONSOLE_CHECKOUT_DIR:-$HOME/xworkspace-console}"
|
||||||
|
if [ -d "$checkout_dir/.git" ]; then
|
||||||
|
info "Updating xworkspace-console checkout at $checkout_dir..."
|
||||||
|
git -C "$checkout_dir" fetch origin
|
||||||
|
git -C "$checkout_dir" reset --hard origin/main
|
||||||
|
else
|
||||||
|
info "Cloning xworkspace-console to $checkout_dir..."
|
||||||
|
git clone "$XWORKSPACE_CONSOLE_REPO_URL" "$checkout_dir"
|
||||||
|
fi
|
||||||
|
cd "$checkout_dir"
|
||||||
|
pwd
|
||||||
|
}
|
||||||
|
|
||||||
|
write_litellm_config() {
|
||||||
|
local config_file=$1
|
||||||
|
mkdir -p "$(dirname "$config_file")"
|
||||||
|
cat > "$config_file" <<'YAML'
|
||||||
|
model_list: []
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: "os.environ/LITELLM_MASTER_KEY"
|
||||||
|
database_url: "os.environ/DATABASE_URL"
|
||||||
|
store_model_in_db: true
|
||||||
|
drop_rate_limit_requests: true
|
||||||
|
|
||||||
|
router_settings:
|
||||||
|
routing_strategy: simple-shuffle
|
||||||
|
num_retries: 2
|
||||||
|
retry_after: 30
|
||||||
|
fallbacks: []
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
drop_params: true
|
||||||
|
set_verbose: false
|
||||||
|
request_timeout: 600
|
||||||
|
telemetry: false
|
||||||
|
YAML
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_secret_file() {
|
||||||
|
local file=$1
|
||||||
|
mkdir -p "$(dirname "$file")"
|
||||||
|
if [ -s "$file" ]; then
|
||||||
|
tr -d '\r\n' < "$file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
openssl rand -hex 20 > "$file"
|
||||||
|
chmod 600 "$file"
|
||||||
|
cat "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_litellm_venv() {
|
||||||
|
local venv_dir=$1
|
||||||
|
local py_bin=$2
|
||||||
|
if [ ! -x "$venv_dir/bin/litellm" ]; then
|
||||||
|
info "Creating LiteLLM virtualenv at $venv_dir..."
|
||||||
|
rm -rf "$venv_dir"
|
||||||
|
"$py_bin" -m venv "$venv_dir"
|
||||||
|
"$venv_dir/bin/python" -m pip install --upgrade pip
|
||||||
|
"$venv_dir/bin/python" -m pip install 'litellm[proxy,extra-proxy]'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! "$venv_dir/bin/python" -c 'import prisma' >/dev/null 2>&1; then
|
||||||
|
info "Adding LiteLLM database dependencies to existing virtualenv..."
|
||||||
|
"$venv_dir/bin/python" -m pip install 'litellm[extra-proxy]'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_litellm_prisma_client() {
|
||||||
|
local venv_dir=$1
|
||||||
|
local database_url=$2
|
||||||
|
local schema_file
|
||||||
|
schema_file="$("$venv_dir/bin/python" - <<'PY'
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
spec = importlib.util.find_spec("litellm.proxy")
|
||||||
|
if spec is None or spec.origin is None:
|
||||||
|
raise SystemExit("Unable to locate litellm.proxy schema")
|
||||||
|
print(pathlib.Path(spec.origin).with_name("schema.prisma"))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
info "Syncing LiteLLM Prisma client and database schema..."
|
||||||
|
PATH="$venv_dir/bin:$PATH" DATABASE_URL="$database_url" "$venv_dir/bin/prisma" db push --schema "$schema_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_local_portal_config() {
|
||||||
|
local token=$1
|
||||||
|
local config_dir=$2
|
||||||
|
mkdir -p "$config_dir"
|
||||||
|
cat > "$config_dir/portal-services.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"key": "litellm",
|
||||||
|
"name": "LiteLLM Admin UI",
|
||||||
|
"url": "http://localhost:4000/ui",
|
||||||
|
"openMode": "iframe",
|
||||||
|
"healthUrl": "http://127.0.0.1:4000/ui",
|
||||||
|
"description": "Model routing and provider administration.",
|
||||||
|
"icon": "chart",
|
||||||
|
"match": ["litellm", "lite"],
|
||||||
|
"port": 4000,
|
||||||
|
"role": "model-router"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "openclaw",
|
||||||
|
"name": "OpenClaw",
|
||||||
|
"url": "http://127.0.0.1:18789/channels",
|
||||||
|
"openMode": "external",
|
||||||
|
"healthUrl": "http://127.0.0.1:18789/channels",
|
||||||
|
"description": "Gateway dashboard.",
|
||||||
|
"icon": "claw",
|
||||||
|
"match": ["openclaw", "gateway"],
|
||||||
|
"port": 18789,
|
||||||
|
"role": "gateway"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vault",
|
||||||
|
"name": "Vault Server",
|
||||||
|
"url": "http://127.0.0.1:8200/ui",
|
||||||
|
"openMode": "external",
|
||||||
|
"healthUrl": "http://127.0.0.1:8200/ui",
|
||||||
|
"description": "Vault UI.",
|
||||||
|
"icon": "shield",
|
||||||
|
"match": ["vault"],
|
||||||
|
"port": 8200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "terminal",
|
||||||
|
"name": "Terminal",
|
||||||
|
"url": "http://127.0.0.1:7681",
|
||||||
|
"openMode": "iframe",
|
||||||
|
"healthUrl": "http://127.0.0.1:7681",
|
||||||
|
"description": "Local ttyd terminal.",
|
||||||
|
"icon": "terminal",
|
||||||
|
"match": ["ttyd", "terminal"],
|
||||||
|
"port": 7681
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf '%s\n' "$token" > "$config_dir/auth-token"
|
||||||
|
chmod 600 "$config_dir/auth-token"
|
||||||
|
printf '%s\n' "$token" > "$AUTH_TOKEN_FILE"
|
||||||
|
chmod 600 "$AUTH_TOKEN_FILE"
|
||||||
|
cat > "$config_dir/portal.env" <<EOF
|
||||||
|
AI_WORKSPACE_AUTH_TOKEN=$token
|
||||||
|
XWORKSPACE_CONSOLE_AUTH_TOKEN=$token
|
||||||
|
BRIDGE_AUTH_TOKEN=$token
|
||||||
|
XWORKMATE_BRIDGE_AUTH_TOKEN=$token
|
||||||
|
INTERNAL_SERVICE_TOKEN=$token
|
||||||
|
LITELLM_MASTER_KEY=$token
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=$token
|
||||||
|
VAULT_TOKEN=$token
|
||||||
|
VAULT_SERVER_ROOT_ACCESS_TOKEN=$token
|
||||||
|
VAULT_ADMIN_PASSWORD=$token
|
||||||
|
XWORKSPACE_PORTAL_SERVICES_FILE=$config_dir/portal-services.json
|
||||||
|
EOF
|
||||||
|
chmod 600 "$config_dir/portal.env"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_managed_pid() {
|
||||||
|
local pid_file=$1
|
||||||
|
if [ ! -f "$pid_file" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
info "Stopping previous managed process $pid..."
|
||||||
|
kill "$pid" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
rm -f "$pid_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_port_available_for_repo() {
|
||||||
|
local port=$1
|
||||||
|
local repo_dir=$2
|
||||||
|
local pid
|
||||||
|
pid="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN | head -n 1 || true)"
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local cwd
|
||||||
|
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
|
||||||
|
if [ -n "$cwd" ] && [[ "$cwd" == "$repo_dir"* ]]; then
|
||||||
|
info "Port $port is already used by this checkout (pid $pid); restarting it."
|
||||||
|
kill "$pid" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
error "Port $port is already in use by pid $pid ($cwd). Stop it or choose a clean local session."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_port_available() {
|
||||||
|
local port=$1
|
||||||
|
local pid
|
||||||
|
pid="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN | head -n 1 || true)"
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local command_name
|
||||||
|
command_name="$(ps -p "$pid" -o comm= 2>/dev/null || true)"
|
||||||
|
error "Port $port is already in use by pid $pid ($command_name). Stop it or choose another port."
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_url() {
|
||||||
|
local url=$1
|
||||||
|
local header=${2:-}
|
||||||
|
local attempts=120
|
||||||
|
local status
|
||||||
|
for _ in $(seq 1 "$attempts"); do
|
||||||
|
if [ -n "$header" ]; then
|
||||||
|
status="$(curl -sS -o /dev/null -w '%{http_code}' -H "$header" "$url" 2>/dev/null || true)"
|
||||||
|
else
|
||||||
|
status="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
case "$status" in
|
||||||
|
2*|3*|401) return 0 ;;
|
||||||
|
esac
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
error "Timed out waiting for $url"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_postgres() {
|
||||||
|
local pg_isready_bin=$1
|
||||||
|
local socket_dir=$2
|
||||||
|
local port=$3
|
||||||
|
local attempts=60
|
||||||
|
for _ in $(seq 1 "$attempts"); do
|
||||||
|
if "$pg_isready_bin" -h "$socket_dir" -p "$port" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
error "Timed out waiting for local PostgreSQL on port $port"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_launch_agent() {
|
||||||
|
local label=$1
|
||||||
|
local workdir=$2
|
||||||
|
local command=$3
|
||||||
|
local stdout_log=$4
|
||||||
|
local stderr_log=$5
|
||||||
|
local plist_dir="$HOME/Library/LaunchAgents"
|
||||||
|
local plist="$plist_dir/$label.plist"
|
||||||
|
local domain="gui/$(id -u)"
|
||||||
|
|
||||||
|
mkdir -p "$plist_dir" "$(dirname "$stdout_log")" "$(dirname "$stderr_log")"
|
||||||
|
cat > "$plist" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>$label</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>-lc</string>
|
||||||
|
<string>$command</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>$workdir</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>$stdout_log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>$stderr_log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
launchctl bootout "$domain" "$plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootstrap "$domain" "$plist" >/dev/null
|
||||||
|
launchctl kickstart -k "$domain/$label" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_macos_litellm_database() {
|
||||||
|
local config_dir=$1
|
||||||
|
local state_dir=$2
|
||||||
|
local tool_path=$3
|
||||||
|
local pg_port="${AI_WORKSPACE_LITELLM_POSTGRES_PORT:-15432}"
|
||||||
|
local pg_data="$HOME/.local/share/xworkspace/postgres-data"
|
||||||
|
local pg_socket_dir="$state_dir/postgres-socket"
|
||||||
|
local db_name="litellm"
|
||||||
|
local db_user="litellm"
|
||||||
|
local db_password_file="$config_dir/litellm-db-password"
|
||||||
|
local db_password postgres_bin initdb_bin psql_bin pg_isready_bin
|
||||||
|
|
||||||
|
db_password="$(ensure_secret_file "$db_password_file")"
|
||||||
|
postgres_bin="$(macos_postgres_tool postgres)"
|
||||||
|
initdb_bin="$(macos_postgres_tool initdb)"
|
||||||
|
psql_bin="$(macos_postgres_tool psql)"
|
||||||
|
pg_isready_bin="$(macos_postgres_tool pg_isready)"
|
||||||
|
|
||||||
|
mkdir -p "$pg_data" "$pg_socket_dir"
|
||||||
|
chmod 700 "$pg_socket_dir"
|
||||||
|
if [ ! -f "$pg_data/PG_VERSION" ]; then
|
||||||
|
info "Initializing local PostgreSQL data directory at $pg_data ..."
|
||||||
|
"$initdb_bin" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256 >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.postgres.plist" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
ensure_port_available "$pg_port"
|
||||||
|
|
||||||
|
info "Starting local PostgreSQL for LiteLLM on 127.0.0.1:$pg_port ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.postgres" \
|
||||||
|
"$HOME" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' '$postgres_bin' -D '$pg_data' -h 127.0.0.1 -p '$pg_port' -k '$pg_socket_dir'" \
|
||||||
|
"$state_dir/postgres.log" \
|
||||||
|
"$state_dir/postgres.err.log"
|
||||||
|
wait_for_postgres "$pg_isready_bin" "$pg_socket_dir" "$pg_port"
|
||||||
|
|
||||||
|
"$psql_bin" -h "$pg_socket_dir" -p "$pg_port" -d postgres -v ON_ERROR_STOP=1 >/dev/null <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$db_user') THEN
|
||||||
|
CREATE ROLE "$db_user" LOGIN PASSWORD '$db_password';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE "$db_user" LOGIN PASSWORD '$db_password';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
SELECT format('CREATE DATABASE %I OWNER %I', '$db_name', '$db_user')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$db_name') \gexec
|
||||||
|
ALTER DATABASE "$db_name" OWNER TO "$db_user";
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PGPASSWORD="$db_password" "$psql_bin" -h 127.0.0.1 -p "$pg_port" -U "$db_user" -d "$db_name" -v ON_ERROR_STOP=1 -Atc 'select 1' >/dev/null
|
||||||
|
printf 'postgresql://%s:%s@127.0.0.1:%s/%s?sslmode=disable\n' "$db_user" "$db_password" "$pg_port" "$db_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_macos_target_services() {
|
||||||
|
local config_dir=$1
|
||||||
|
local state_dir=$2
|
||||||
|
local tool_path=$3
|
||||||
|
local litellm_py litellm_venv litellm_config litellm_bin openclaw_bin vault_bin ttyd_bin litellm_database_url
|
||||||
|
|
||||||
|
chmod 700 "$state_dir"
|
||||||
|
litellm_py="$(macos_litellm_python)"
|
||||||
|
litellm_venv="$HOME/.local/share/xworkspace/litellm-venv"
|
||||||
|
litellm_config="$config_dir/litellm-config.yaml"
|
||||||
|
ensure_litellm_venv "$litellm_venv" "$litellm_py"
|
||||||
|
litellm_database_url="$(ensure_macos_litellm_database "$config_dir" "$state_dir" "$tool_path")"
|
||||||
|
ensure_litellm_prisma_client "$litellm_venv" "$litellm_database_url"
|
||||||
|
write_litellm_config "$litellm_config"
|
||||||
|
litellm_bin="$litellm_venv/bin/litellm"
|
||||||
|
openclaw_bin="$(macos_openclaw_bin)"
|
||||||
|
vault_bin="$(macos_vault_bin)"
|
||||||
|
ttyd_bin="$(macos_ttyd_bin)"
|
||||||
|
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
info "Starting LiteLLM on http://127.0.0.1:4000 ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.litellm" \
|
||||||
|
"$HOME" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' DATABASE_URL='$litellm_database_url' LITELLM_MASTER_KEY=\"\$(cat '$config_dir/auth-token')\" LITELLM_SALT_KEY=\"\$(cat '$config_dir/auth-token')\" UI_USERNAME=admin UI_PASSWORD=\"\$(cat '$config_dir/auth-token')\" '$litellm_bin' --host 127.0.0.1 --port 4000 --config '$litellm_config' --use_prisma_db_push" \
|
||||||
|
"$state_dir/litellm.log" \
|
||||||
|
"$state_dir/litellm.err.log"
|
||||||
|
wait_for_url "http://127.0.0.1:4000/ui"
|
||||||
|
|
||||||
|
info "Starting OpenClaw on http://127.0.0.1:18789/channels ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.openclaw" \
|
||||||
|
"$HOME" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' OPENCLAW_GATEWAY_TOKEN=\"\$(cat '$config_dir/auth-token')\" '$openclaw_bin' gateway run --dev --force --bind loopback --auth token --token \"\$(cat '$config_dir/auth-token')\" --port 18789" \
|
||||||
|
"$state_dir/openclaw.log" \
|
||||||
|
"$state_dir/openclaw.err.log"
|
||||||
|
wait_for_url "http://127.0.0.1:18789/channels"
|
||||||
|
|
||||||
|
info "Starting Vault on http://127.0.0.1:8200/ui ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.vault" \
|
||||||
|
"$HOME" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' VAULT_ADDR=http://127.0.0.1:8200 '$vault_bin' server -dev -dev-listen-address=127.0.0.1:8200 -dev-root-token-id=\"\$(cat '$config_dir/auth-token')\"" \
|
||||||
|
"$state_dir/vault.log" \
|
||||||
|
"$state_dir/vault.err.log"
|
||||||
|
wait_for_url "http://127.0.0.1:8200/ui"
|
||||||
|
|
||||||
|
info "Starting ttyd terminal on http://127.0.0.1:7681 ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.ttyd" \
|
||||||
|
"$HOME" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' '$ttyd_bin' -W -i 127.0.0.1 -p 7681 -w '$HOME' /bin/zsh -l" \
|
||||||
|
"$state_dir/ttyd.log" \
|
||||||
|
"$state_dir/ttyd.err.log"
|
||||||
|
wait_for_url "http://127.0.0.1:7681/"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_macos_local() {
|
||||||
|
require_or_install_macos_cmds
|
||||||
|
|
||||||
|
local token=$1
|
||||||
|
local console_dir config_dir state_dir api_log dashboard_log api_err dashboard_err go_bin npm_bin node_bin tool_path
|
||||||
|
console_dir="$(resolve_console_dir)"
|
||||||
|
config_dir="$HOME/.config/xworkspace"
|
||||||
|
state_dir="$HOME/.local/state/xworkspace"
|
||||||
|
go_bin="$(command -v go)"
|
||||||
|
npm_bin="$(command -v npm)"
|
||||||
|
node_bin="$(command -v node)"
|
||||||
|
tool_path="$(dirname "$node_bin"):$(dirname "$go_bin"):$(dirname "$npm_bin"):/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
mkdir -p "$state_dir"
|
||||||
|
api_log="$state_dir/xworkspace-api.log"
|
||||||
|
dashboard_log="$state_dir/xworkspace-console.log"
|
||||||
|
api_err="$state_dir/xworkspace-api.err.log"
|
||||||
|
dashboard_err="$state_dir/xworkspace-console.err.log"
|
||||||
|
|
||||||
|
info "Deploying AI Workspace Portal locally on macOS from $console_dir"
|
||||||
|
write_local_portal_config "$token" "$config_dir"
|
||||||
|
|
||||||
|
stop_managed_pid "$state_dir/xworkspace-api.pid"
|
||||||
|
stop_managed_pid "$state_dir/xworkspace-console.pid"
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.api.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.console.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
|
||||||
|
ensure_port_available_for_repo 8788 "$console_dir"
|
||||||
|
ensure_port_available_for_repo 17000 "$console_dir"
|
||||||
|
ensure_port_available_for_repo 4000 "$console_dir"
|
||||||
|
ensure_port_available_for_repo 18789 "$console_dir"
|
||||||
|
ensure_port_available_for_repo 8200 "$console_dir"
|
||||||
|
ensure_port_available_for_repo 7681 "$console_dir"
|
||||||
|
|
||||||
|
info "Building dashboard assets..."
|
||||||
|
(cd "$console_dir/dashboard" && npm install && npm run build)
|
||||||
|
|
||||||
|
start_macos_target_services "$config_dir" "$state_dir" "$tool_path"
|
||||||
|
|
||||||
|
info "Starting xworkspace API on http://127.0.0.1:8788 ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.api" \
|
||||||
|
"$console_dir/api" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' XWORKSPACE_PORTAL_SERVICES_FILE='$config_dir/portal-services.json' '$go_bin' run ." \
|
||||||
|
"$api_log" \
|
||||||
|
"$api_err"
|
||||||
|
wait_for_url "http://127.0.0.1:8788/auth/status"
|
||||||
|
|
||||||
|
info "Starting AI Workspace Portal on http://127.0.0.1:17000 ..."
|
||||||
|
deploy_launch_agent \
|
||||||
|
"plus.svc.xworkspace.console" \
|
||||||
|
"$console_dir/dashboard" \
|
||||||
|
"exec /usr/bin/env PATH='$tool_path' '$npm_bin' run preview -- --host 127.0.0.1 --port 17000" \
|
||||||
|
"$dashboard_log" \
|
||||||
|
"$dashboard_err"
|
||||||
|
wait_for_url "http://127.0.0.1:17000/"
|
||||||
|
|
||||||
|
local status_ok status_bad
|
||||||
|
status_ok="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" http://127.0.0.1:8788/portal/services)"
|
||||||
|
status_bad="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token" http://127.0.0.1:8788/portal/services)"
|
||||||
|
[ "$status_ok" = "200" ] || error "Expected valid token to unlock portal services, got HTTP $status_ok"
|
||||||
|
[ "$status_bad" = "401" ] || error "Expected invalid token to be rejected, got HTTP $status_bad"
|
||||||
|
|
||||||
|
success "AI Workspace Portal is running at http://127.0.0.1:17000/"
|
||||||
|
info "Use the same xworkmate-bridge token to unlock the Portal."
|
||||||
|
info "Logs: $api_log, $api_err, $dashboard_log, and $dashboard_err"
|
||||||
|
}
|
||||||
|
|
||||||
|
info "Starting AI Workspace All-in-One Bootstrap..."
|
||||||
|
|
||||||
|
# 1. Install prerequisites (git, curl, ansible) if missing
|
||||||
|
OS_NAME="$(detect_os)"
|
||||||
|
if [ "$OS_NAME" = "darwin" ] && [ "${AI_WORKSPACE_DARWIN_MODE:-local}" = "local" ]; then
|
||||||
|
UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
|
||||||
|
export AI_WORKSPACE_AUTH_TOKEN="$UNIFIED_AUTH_TOKEN"
|
||||||
|
export XWORKSPACE_CONSOLE_AUTH_TOKEN="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export INTERNAL_SERVICE_TOKEN="${INTERNAL_SERVICE_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export XWORKMATE_BRIDGE_AUTH_TOKEN="${XWORKMATE_BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_TOKEN="${VAULT_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_SERVER_ROOT_ACCESS_TOKEN="${VAULT_SERVER_ROOT_ACCESS_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_ADMIN_PASSWORD="${VAULT_ADMIN_PASSWORD:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
deploy_macos_local "$UNIFIED_AUTH_TOKEN"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
|
||||||
|
install_prerequisites "$OS_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Clone Repository
|
# 2. Clone Repository
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -n "$PLAYBOOK_DIR" ]; then
|
||||||
|
[ -d "$PLAYBOOK_DIR" ] || error "PLAYBOOK_DIR does not exist: $PLAYBOOK_DIR"
|
||||||
|
info "Using local playbooks repository at $PLAYBOOK_DIR"
|
||||||
|
cd "$PLAYBOOK_DIR"
|
||||||
|
elif [ -d "$TARGET_DIR" ]; then
|
||||||
info "Updating existing repository in $TARGET_DIR..."
|
info "Updating existing repository in $TARGET_DIR..."
|
||||||
cd "$TARGET_DIR"
|
cd "$TARGET_DIR"
|
||||||
git fetch origin
|
git fetch origin
|
||||||
@ -66,16 +767,25 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Construct Ansible variables from Environment Variables
|
# 3. Construct Ansible variables from Environment Variables
|
||||||
ANSIBLE_EXTRA_VARS=""
|
ANSIBLE_EXTRA_VARS=()
|
||||||
|
|
||||||
# Helper function to append to extra vars if set
|
# Helper function to append to extra vars if set
|
||||||
append_var() {
|
append_var() {
|
||||||
local env_name=$1
|
local env_name=$1
|
||||||
local ansible_var=$2
|
local ansible_var=$2
|
||||||
local val="${!env_name}"
|
local val="${!env_name:-}"
|
||||||
if [ -n "$val" ]; then
|
if [ -n "$val" ]; then
|
||||||
info "Applying parameter: $ansible_var = $val"
|
info "Applying parameter: $ansible_var = $val"
|
||||||
ANSIBLE_EXTRA_VARS="$ANSIBLE_EXTRA_VARS -e \"$ansible_var=$val\""
|
ANSIBLE_EXTRA_VARS+=("-e" "$ansible_var=$val")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
append_secret_var() {
|
||||||
|
local ansible_var=$1
|
||||||
|
local val=$2
|
||||||
|
if [ -n "$val" ]; then
|
||||||
|
info "Applying secret parameter: $ansible_var = $(mask_secret "$val")"
|
||||||
|
ANSIBLE_EXTRA_VARS+=("-e" "$ansible_var=$val")
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,29 +797,56 @@ append_var "GATEWAY_OPENCLAW_PUBLIC_ACCESS" "gateway_openclaw_public_access"
|
|||||||
append_var "VAULT_PUBLIC_ACCESS" "vault_public_access"
|
append_var "VAULT_PUBLIC_ACCESS" "vault_public_access"
|
||||||
append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp"
|
append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp"
|
||||||
|
|
||||||
# 4. Handle Vault Password (Auth Token)
|
# 4. Resolve one auth token for the bridge and downstream service UIs/APIs.
|
||||||
# If DEPLOY_TOKEN is provided, use it. Otherwise, generate a random one or reuse existing.
|
UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
|
||||||
VAULT_FILE="$HOME/.vault_password"
|
append_secret_var "ai_workspace_auth_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "xworkspace_console_auth_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "xworkmate_bridge_auth_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "litellm_master_key" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "litellm_ui_password" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "gateway_openclaw_gateway_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "vault_server_root_access_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "vault_root_token" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
append_secret_var "vault_admin_password" "$UNIFIED_AUTH_TOKEN"
|
||||||
|
ANSIBLE_EXTRA_VARS+=("-e" "vault_admin_init_enabled=true")
|
||||||
|
|
||||||
if [ -n "$DEPLOY_TOKEN" ]; then
|
# Export environment fallbacks for roles/scripts that read environment directly.
|
||||||
echo "$DEPLOY_TOKEN" > "$VAULT_FILE"
|
export AI_WORKSPACE_AUTH_TOKEN="$UNIFIED_AUTH_TOKEN"
|
||||||
info "Using provided DEPLOY_TOKEN as the Vault password."
|
export XWORKSPACE_CONSOLE_AUTH_TOKEN="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export INTERNAL_SERVICE_TOKEN="${INTERNAL_SERVICE_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export XWORKMATE_BRIDGE_AUTH_TOKEN="${XWORKMATE_BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_TOKEN="${VAULT_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_SERVER_ROOT_ACCESS_TOKEN="${VAULT_SERVER_ROOT_ACCESS_TOKEN:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
export VAULT_ADMIN_PASSWORD="${VAULT_ADMIN_PASSWORD:-$UNIFIED_AUTH_TOKEN}"
|
||||||
|
|
||||||
|
# 5. Handle Ansible Vault password.
|
||||||
|
# Keep this separate from the runtime auth token, but reuse DEPLOY_TOKEN for
|
||||||
|
# backward compatibility when no explicit vault password is provided.
|
||||||
|
if [ -n "${ANSIBLE_VAULT_PASSWORD:-}" ]; then
|
||||||
|
printf '%s' "$ANSIBLE_VAULT_PASSWORD" > "$VAULT_FILE"
|
||||||
|
info "Using provided ANSIBLE_VAULT_PASSWORD for Ansible Vault."
|
||||||
|
elif [ -n "${DEPLOY_TOKEN:-}" ]; then
|
||||||
|
printf '%s' "$DEPLOY_TOKEN" > "$VAULT_FILE"
|
||||||
|
info "Using DEPLOY_TOKEN as the Ansible Vault password for backward compatibility."
|
||||||
elif [ -f "$VAULT_FILE" ]; then
|
elif [ -f "$VAULT_FILE" ]; then
|
||||||
info "Found existing Vault password at $VAULT_FILE, reusing it."
|
info "Found existing Ansible Vault password at $VAULT_FILE, reusing it."
|
||||||
else
|
else
|
||||||
info "No DEPLOY_TOKEN provided and no existing vault password found. Generating a secure random token..."
|
info "No Ansible Vault password provided. Generating a secure random password..."
|
||||||
# Generate a random 32-character token
|
|
||||||
openssl rand -base64 32 > "$VAULT_FILE"
|
openssl rand -base64 32 > "$VAULT_FILE"
|
||||||
info "Generated new Vault password and saved to $VAULT_FILE"
|
info "Generated new Ansible Vault password and saved to $VAULT_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure correct permissions for the vault file
|
# Ensure correct permissions for the vault file
|
||||||
chmod 600 "$VAULT_FILE"
|
chmod 600 "$VAULT_FILE"
|
||||||
VAULT_OPT="--vault-password-file $VAULT_FILE"
|
|
||||||
|
|
||||||
# 5. Run Ansible Playbook locally
|
# 6. Run Ansible Playbook locally
|
||||||
info "Running Ansible Playbook locally..."
|
info "Running Ansible Playbook locally..."
|
||||||
eval "ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml $VAULT_OPT $ANSIBLE_EXTRA_VARS"
|
ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml \
|
||||||
|
--vault-password-file "$VAULT_FILE" \
|
||||||
|
"${ANSIBLE_EXTRA_VARS[@]}"
|
||||||
RET=$?
|
RET=$?
|
||||||
|
|
||||||
if [ $RET -eq 0 ]; then
|
if [ $RET -eq 0 ]; then
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user