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 {
|
||||
serviceProbe ServiceProbe
|
||||
metricProbe MetricProbe
|
||||
serviceProbe ServiceProbe
|
||||
metricProbe MetricProbe
|
||||
portalServices PortalServiceProvider
|
||||
auth AuthConfig
|
||||
}
|
||||
|
||||
func NewServer() *Server {
|
||||
serviceProbe := NewServiceProbe()
|
||||
return &Server{
|
||||
serviceProbe: serviceProbe,
|
||||
metricProbe: NewMetricProbe(),
|
||||
serviceProbe: serviceProbe,
|
||||
metricProbe: NewMetricProbe(),
|
||||
portalServices: NewPortalServiceProvider(),
|
||||
auth: NewAuthConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,12 +30,11 @@ func (s *Server) ListenAndServe(addr string) error {
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/status", s.authStatusHandler)
|
||||
mux.HandleFunc("/health", s.healthHandler)
|
||||
mux.HandleFunc("/services", s.servicesHandler)
|
||||
mux.HandleFunc("/portal/services", s.portalServicesHandler)
|
||||
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
|
||||
}
|
||||
|
||||
@ -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-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 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
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) {
|
||||
if !s.requireAuth(w, r) {
|
||||
return
|
||||
}
|
||||
services := s.serviceProbe.Probe()
|
||||
writeJSON(w, HealthResponse{
|
||||
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) {
|
||||
if !s.requireAuth(w, r) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if !s.requireAuth(w, r) {
|
||||
return
|
||||
}
|
||||
_, _ = 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) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
21
api/types.go
21
api/types.go
@ -9,6 +9,27 @@ type Service struct {
|
||||
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 {
|
||||
Status string `json:"status"`
|
||||
Arch string `json:"arch"`
|
||||
|
||||
@ -6,7 +6,9 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
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
|
||||
RestartSec=2
|
||||
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh } from '@/lib/data';
|
||||
import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data';
|
||||
import { fetchDashboardStatus } from '@/lib/api';
|
||||
import {
|
||||
buildInitialTabs,
|
||||
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 { Topbar } from './Topbar';
|
||||
import { WorkspaceTabs } from './WorkspaceTabs';
|
||||
import { WorkspaceHome } from './WorkspaceHome';
|
||||
import { EmbedView } from './EmbedView';
|
||||
import { ServicePanel } from './ServicePanel';
|
||||
|
||||
export function AppShell() {
|
||||
const [selectedTab, setSelectedTab] = useState('workspace');
|
||||
@ -19,14 +30,33 @@ export function AppShell() {
|
||||
const [language, setLanguage] = useState<'en' | 'zh'>('en');
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
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(() => {
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
fetchDashboardStatus().then((data) => {
|
||||
if (!active || !data) return;
|
||||
setServices(data.services);
|
||||
setMetrics(data.metrics);
|
||||
if (!tokenLoaded || !authStatusLoaded || (authRequired && !authToken)) return;
|
||||
Promise.all([fetchDashboardStatus(authToken), fetchPortalServices(authToken)]).then(([statusResult, portalResult]) => {
|
||||
if (!active) return;
|
||||
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();
|
||||
@ -35,6 +65,29 @@ export function AppShell() {
|
||||
active = false;
|
||||
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(() => {
|
||||
@ -71,7 +124,9 @@ export function AppShell() {
|
||||
|
||||
const currentServices = services ?? [];
|
||||
const selected = tabs.find((tab) => tab.id === selectedTab);
|
||||
const selectedService = findPortalService(selected?.serviceKey, portalServicesConfig);
|
||||
const labels = language === 'zh' ? labelsZh : labelsEn;
|
||||
const dynamicNavSections = useMemo(() => buildNavSections(portalServicesConfig), [portalServicesConfig]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
|
||||
@ -79,11 +134,15 @@ export function AppShell() {
|
||||
}, [currentServices, metrics]);
|
||||
|
||||
const openTab = (item: NavItem | Tab) => {
|
||||
const service = findPortalService(item.serviceKey, portalServicesConfig);
|
||||
const nextItem = service ? portalServiceToTab(service) : item;
|
||||
setTabs((existingTabs) => {
|
||||
if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs;
|
||||
return [...existingTabs, { ...item, closable: true }];
|
||||
if (existingTabs.some((tab) => tab.id === nextItem.id)) {
|
||||
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) => {
|
||||
@ -99,10 +158,35 @@ export function AppShell() {
|
||||
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 (
|
||||
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}>
|
||||
<Sidebar
|
||||
labels={labels}
|
||||
navSections={dynamicNavSections}
|
||||
collapsed={sidebarCollapsed}
|
||||
selectedTab={selectedTab}
|
||||
onToggle={() => setSidebarCollapsed((value) => !value)}
|
||||
@ -126,12 +210,53 @@ export function AppShell() {
|
||||
|
||||
<WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} />
|
||||
|
||||
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
|
||||
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} />
|
||||
{selected?.kind === 'embed' && selectedService ? (
|
||||
<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>
|
||||
</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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data';
|
||||
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data';
|
||||
import { acpAgents, findPortalService, findPortalServiceByRole, findPortalServiceStatus, portalServiceToNavItem, skillGroups } from '@/lib/data';
|
||||
import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function ArchPipeline({
|
||||
labels,
|
||||
services,
|
||||
metrics,
|
||||
portalServices,
|
||||
onOpenService,
|
||||
}: {
|
||||
labels: Labels;
|
||||
services: Service[];
|
||||
metrics: RuntimeMetrics;
|
||||
portalServices: PortalService[];
|
||||
onOpenService: (item: NavItem) => void;
|
||||
}) {
|
||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||
const gatewayService = findPortalServiceByRole('gateway', portalServices);
|
||||
const modelRouterService = findPortalServiceByRole('model-router', portalServices);
|
||||
|
||||
const stateOf = (id: string): Service['state'] | undefined => {
|
||||
const def = serviceRegistry.find((item) => item.id === id);
|
||||
const service = services.find((entry) => def?.match?.some((token) => entry.name.toLowerCase().includes(token)));
|
||||
return service?.state;
|
||||
const openPortalService = (serviceKey?: string) => {
|
||||
const serviceConfig = findPortalService(serviceKey, portalServices);
|
||||
if (serviceConfig) onOpenService(portalServiceToNavItem(serviceConfig));
|
||||
};
|
||||
const dot = (state?: Service['state']) => (
|
||||
<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>
|
||||
</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">
|
||||
<span className="node-index blue">1</span>
|
||||
<span className="node-title">{labels.gatewayBand}</span>
|
||||
</div>
|
||||
{dot(stateOf('openclaw'))}
|
||||
<strong>OpenClaw Gateway</strong>
|
||||
<small>v2026.6.1</small>
|
||||
<small>127.0.0.1:18789</small>
|
||||
<small>token auth</small>
|
||||
{dot(findPortalServiceStatus(services, gatewayService?.key, portalServices))}
|
||||
<strong>{gatewayService?.name ?? labels.gatewayBand}</strong>
|
||||
<small>{gatewayService?.description ?? 'Gateway service'}</small>
|
||||
<small>{gatewayService?.url ?? 'Not configured'}</small>
|
||||
<small>Local Only</small>
|
||||
</button>
|
||||
</div>
|
||||
@ -98,7 +100,7 @@ export function ArchPipeline({
|
||||
<span className="node-index green">3</span>
|
||||
<strong>{labels.skillBand} Layer</strong>
|
||||
<small>{totalSkills}+ {labels.skillsCount}</small>
|
||||
{dot(stateOf('bridge'))}
|
||||
{dot()}
|
||||
</div>
|
||||
<div className="skill-stack">
|
||||
{skillGroups.map((group) => (
|
||||
@ -119,12 +121,12 @@ export function ArchPipeline({
|
||||
<div><span>Skills</span><b>{totalSkills}+</b><Icon name="sparkles" /></div>
|
||||
</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">
|
||||
<span className="node-index amber">4</span>
|
||||
<strong>{labels.modelBand} Layer</strong>
|
||||
<small>LiteLLM · 4000 · OpenAI-compatible · Anthropic-compatible</small>
|
||||
{dot(stateOf('litellm'))}
|
||||
<small>{modelRouterService ? `${modelRouterService.name} · ${modelRouterService.url}` : 'Not configured'}</small>
|
||||
{dot(findPortalServiceStatus(services, modelRouterService?.key, portalServices))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -145,5 +147,3 @@ export function ArchPipeline({
|
||||
</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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { findServiceDef } from '@/lib/data';
|
||||
import { findPortalServiceForStatus } from '@/lib/data';
|
||||
import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@ -17,14 +17,14 @@ export function PanelsRow({ labels, services, metrics }: { labels: Labels; servi
|
||||
</div>
|
||||
<div className="health-row">
|
||||
{services.map((service) => {
|
||||
const def = findServiceDef(service.name);
|
||||
const portalService = findPortalServiceForStatus(service.name);
|
||||
const running = service.state === 'Running';
|
||||
return (
|
||||
<div className="health-item" key={service.name} title={service.name}>
|
||||
<span className={running ? 'health-icon good' : 'health-icon bad'}>
|
||||
<Icon name={def?.icon ?? 'cube'} />
|
||||
<Icon name={portalService?.icon ?? 'cube'} />
|
||||
</span>
|
||||
<small>{def?.label ?? service.name}</small>
|
||||
<small>{portalService?.name ?? service.name}</small>
|
||||
<em>{running ? labels.healthy : labels.degraded}</em>
|
||||
</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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { navSections } from '@/lib/data';
|
||||
import type { Labels, NavItem } from '@/lib/data';
|
||||
import type { Labels, NavItem, NavSection } from '@/lib/data';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export function Sidebar({
|
||||
labels,
|
||||
navSections,
|
||||
collapsed,
|
||||
selectedTab,
|
||||
onToggle,
|
||||
@ -18,6 +18,7 @@ export function Sidebar({
|
||||
onToggleRemoteMode,
|
||||
}: {
|
||||
labels: Labels;
|
||||
navSections: NavSection[];
|
||||
collapsed: boolean;
|
||||
selectedTab: string;
|
||||
onToggle: () => void;
|
||||
|
||||
@ -20,6 +20,7 @@ export function TerminalDrawer({
|
||||
const [height, setHeight] = useState(250);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStart = useRef({ y: 0, height: 250 });
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const onDragStart = (event: React.PointerEvent) => {
|
||||
if (expanded || collapsed) return;
|
||||
@ -42,6 +43,11 @@ export function TerminalDrawer({
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const focusTerminal = () => {
|
||||
iframeRef.current?.focus();
|
||||
iframeRef.current?.contentWindow?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}>
|
||||
<div
|
||||
@ -71,7 +77,18 @@ export function TerminalDrawer({
|
||||
</div>
|
||||
<div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}>
|
||||
{!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}
|
||||
</div>
|
||||
</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';
|
||||
|
||||
export function WorkspaceHome({
|
||||
labels,
|
||||
services,
|
||||
metrics,
|
||||
portalServices,
|
||||
onOpenService,
|
||||
}: {
|
||||
labels: Labels;
|
||||
services: Service[];
|
||||
metrics: RuntimeMetrics;
|
||||
portalServices: PortalService[];
|
||||
onOpenService: (item: NavItem) => void;
|
||||
}) {
|
||||
return (
|
||||
@ -23,7 +25,7 @@ export function WorkspaceHome({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArchPipeline labels={labels} services={services} metrics={metrics} onOpenService={onOpenService} />
|
||||
<ArchPipeline labels={labels} services={services} metrics={metrics} portalServices={portalServices} onOpenService={onOpenService} />
|
||||
</div>
|
||||
</section>
|
||||
</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';
|
||||
|
||||
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'] => {
|
||||
if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
|
||||
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),
|
||||
});
|
||||
|
||||
export async function fetchDashboardStatus(): Promise<DashboardStatus | null> {
|
||||
export async function fetchAuthStatus(): Promise<{ required: boolean } | null> {
|
||||
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;
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.services)) return null;
|
||||
return {
|
||||
services: data.services.map(mapService),
|
||||
metrics: {
|
||||
activeSessions: Number(data.metrics?.activeSessions ?? 0),
|
||||
connectedAgents: Number(data.metrics?.connectedAgents ?? 0),
|
||||
activeModels: Number(data.metrics?.activeModels ?? 0),
|
||||
skillsAvailable: Number(data.metrics?.skillsAvailable ?? 0),
|
||||
workers: Number(data.metrics?.workers ?? 0),
|
||||
},
|
||||
};
|
||||
return { required: Boolean(data.required) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServices(): Promise<Service[] | null> {
|
||||
export async function fetchDashboardStatus(token?: string): Promise<ApiResult<DashboardStatus>> {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/services`, { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const response = await fetch(`${BASE}/health`, { 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 null;
|
||||
return data.map(mapService);
|
||||
if (!Array.isArray(data.services)) return { data: null, unauthorized: false };
|
||||
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 {
|
||||
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;
|
||||
closable?: boolean;
|
||||
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 = {
|
||||
@ -37,47 +50,135 @@ export type NavItem = {
|
||||
icon: string;
|
||||
href: string;
|
||||
kind: Tab['kind'];
|
||||
serviceKey?: string;
|
||||
};
|
||||
|
||||
export type ServiceDef = NavItem & {
|
||||
export type NavSectionItem = NavItem & {
|
||||
group: number;
|
||||
port?: number;
|
||||
match?: string[];
|
||||
frameMode?: Tab['frameMode'];
|
||||
};
|
||||
|
||||
export const serviceRegistry: ServiceDef[] = [
|
||||
{ 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'] },
|
||||
{ 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'] },
|
||||
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] },
|
||||
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal', href: 'http://127.0.0.1:7681', kind: 'embed', group: 2, port: 7681 },
|
||||
export type NavSection = { id: string; titleKey: string; items: NavSectionItem[] };
|
||||
|
||||
export const portalServices: PortalService[] = [
|
||||
{
|
||||
key: 'litellm',
|
||||
name: 'LiteLLM Admin UI',
|
||||
url: 'http://localhost:4000/ui',
|
||||
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',
|
||||
titleKey: '',
|
||||
items: [
|
||||
serviceRegistry.find((item) => item.id === 'workspace')!,
|
||||
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
|
||||
],
|
||||
items: workspaceNavItems,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
titleKey: 'navServices',
|
||||
items: [
|
||||
...serviceRegistry.filter((item) => item.group === 1),
|
||||
serviceRegistry.find((item) => item.id === 'terminal')!,
|
||||
],
|
||||
items: services.map(portalServiceToNavItem),
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
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[] = [
|
||||
@ -85,10 +186,13 @@ export const customWorkspaceTabs: Tab[] = [
|
||||
{ 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' },
|
||||
...services.map(portalServiceToTab),
|
||||
];
|
||||
|
||||
export const initialTabs = buildInitialTabs();
|
||||
|
||||
export const fallbackMetrics: RuntimeMetrics = {
|
||||
activeSessions: 0,
|
||||
connectedAgents: 0,
|
||||
|
||||
@ -40,6 +40,72 @@ button {
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@ -934,7 +1000,7 @@ button {
|
||||
aspect-ratio: 16 / 10;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.external-embed-fallback > div {
|
||||
@ -952,7 +1018,7 @@ button {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background: var(--soft);
|
||||
color: var(--accent);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.external-embed-icon .icon {
|
||||
@ -975,15 +1041,21 @@ button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-radius: 7px;
|
||||
background: var(--text);
|
||||
color: var(--panel);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.external-embed-fallback a .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.terminal-drawer {
|
||||
margin: 18px 22px 28px;
|
||||
padding: 18px;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# AI Workspace All-in-One Bootstrap Script
|
||||
@ -15,46 +15,747 @@ set -e
|
||||
# GATEWAY_OPENCLAW_PUBLIC_ACCESS
|
||||
# VAULT_PUBLIC_ACCESS
|
||||
# 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"}
|
||||
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
|
||||
info() {
|
||||
echo -e "\033[1;34m[INFO]\033[0m $*"
|
||||
echo -e "\033[1;34m[INFO]\033[0m $*" >&2
|
||||
}
|
||||
success() {
|
||||
echo -e "\033[1;32m[SUCCESS]\033[0m $*"
|
||||
echo -e "\033[1;32m[SUCCESS]\033[0m $*" >&2
|
||||
}
|
||||
error() {
|
||||
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
|
||||
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
|
||||
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_prerequisites() {
|
||||
local os="$1"
|
||||
info "Installing required dependencies (git, ansible)..."
|
||||
if [ -f /etc/debian_version ]; then
|
||||
sudo apt-get update -y
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common
|
||||
sudo apt-add-repository --yes --update ppa:ansible/ansible
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
sudo yum install -y epel-release
|
||||
sudo yum install -y git curl ansible
|
||||
if [ "$os" = "linux" ]; then
|
||||
if [ -f /etc/debian_version ]; then
|
||||
sudo apt-get update -y
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common
|
||||
sudo apt-add-repository --yes --update ppa:ansible/ansible
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
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
|
||||
error "Unsupported OS. Please install git and ansible manually."
|
||||
fi
|
||||
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
|
||||
|
||||
# 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..."
|
||||
cd "$TARGET_DIR"
|
||||
git fetch origin
|
||||
@ -66,16 +767,25 @@ else
|
||||
fi
|
||||
|
||||
# 3. Construct Ansible variables from Environment Variables
|
||||
ANSIBLE_EXTRA_VARS=""
|
||||
ANSIBLE_EXTRA_VARS=()
|
||||
|
||||
# Helper function to append to extra vars if set
|
||||
append_var() {
|
||||
local env_name=$1
|
||||
local ansible_var=$2
|
||||
local val="${!env_name}"
|
||||
local val="${!env_name:-}"
|
||||
if [ -n "$val" ]; then
|
||||
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
|
||||
}
|
||||
|
||||
@ -87,29 +797,56 @@ append_var "GATEWAY_OPENCLAW_PUBLIC_ACCESS" "gateway_openclaw_public_access"
|
||||
append_var "VAULT_PUBLIC_ACCESS" "vault_public_access"
|
||||
append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp"
|
||||
|
||||
# 4. Handle Vault Password (Auth Token)
|
||||
# If DEPLOY_TOKEN is provided, use it. Otherwise, generate a random one or reuse existing.
|
||||
VAULT_FILE="$HOME/.vault_password"
|
||||
# 4. Resolve one auth token for the bridge and downstream service UIs/APIs.
|
||||
UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
|
||||
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
|
||||
echo "$DEPLOY_TOKEN" > "$VAULT_FILE"
|
||||
info "Using provided DEPLOY_TOKEN as the Vault password."
|
||||
# Export environment fallbacks for roles/scripts that read environment directly.
|
||||
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}"
|
||||
|
||||
# 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
|
||||
info "Found existing Vault password at $VAULT_FILE, reusing it."
|
||||
info "Found existing Ansible Vault password at $VAULT_FILE, reusing it."
|
||||
else
|
||||
info "No DEPLOY_TOKEN provided and no existing vault password found. Generating a secure random token..."
|
||||
# Generate a random 32-character token
|
||||
info "No Ansible Vault password provided. Generating a secure random password..."
|
||||
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
|
||||
|
||||
# Ensure correct permissions for the 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..."
|
||||
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=$?
|
||||
|
||||
if [ $RET -eq 0 ]; then
|
||||
|
||||
Loading…
Reference in New Issue
Block a user