@nexus-ai-fs/tui 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/package.json +48 -0
- package/src/app.tsx +349 -0
- package/src/index.tsx +137 -0
- package/src/opentui-env.d.ts +61 -0
- package/src/panels/access/access-panel.tsx +597 -0
- package/src/panels/access/alert-list.tsx +77 -0
- package/src/panels/access/constraint-creator.tsx +128 -0
- package/src/panels/access/constraint-list.tsx +72 -0
- package/src/panels/access/credential-list.tsx +68 -0
- package/src/panels/access/delegation-chain-view.tsx +110 -0
- package/src/panels/access/delegation-completer.tsx +120 -0
- package/src/panels/access/delegation-creator.tsx +237 -0
- package/src/panels/access/delegation-list.tsx +74 -0
- package/src/panels/access/fraud-score-view.tsx +94 -0
- package/src/panels/access/manifest-creator.tsx +167 -0
- package/src/panels/access/manifest-list.tsx +105 -0
- package/src/panels/access/namespace-config-view.tsx +525 -0
- package/src/panels/access/permission-checker.tsx +231 -0
- package/src/panels/agents/agent-status-view.tsx +196 -0
- package/src/panels/agents/agents-panel.tsx +493 -0
- package/src/panels/agents/delegation-list.tsx +154 -0
- package/src/panels/agents/inbox-view.tsx +96 -0
- package/src/panels/agents/trajectories-tab.tsx +40 -0
- package/src/panels/api-console/api-console-panel.tsx +189 -0
- package/src/panels/api-console/codegen-viewer.tsx +36 -0
- package/src/panels/api-console/codegen.ts +112 -0
- package/src/panels/api-console/endpoint-list.tsx +57 -0
- package/src/panels/api-console/request-builder.tsx +69 -0
- package/src/panels/api-console/response-viewer.tsx +54 -0
- package/src/panels/connectors/available-tab.tsx +357 -0
- package/src/panels/connectors/connector-row.tsx +121 -0
- package/src/panels/connectors/connectors-panel.tsx +88 -0
- package/src/panels/connectors/error-parser.ts +116 -0
- package/src/panels/connectors/mounted-tab.tsx +179 -0
- package/src/panels/connectors/skills-tab.tsx +235 -0
- package/src/panels/connectors/template-generator.ts +211 -0
- package/src/panels/connectors/write-tab.tsx +514 -0
- package/src/panels/events/audit-tab.tsx +69 -0
- package/src/panels/events/audit-trail.tsx +75 -0
- package/src/panels/events/connector-detail.tsx +49 -0
- package/src/panels/events/connector-list.tsx +73 -0
- package/src/panels/events/connectors-tab.tsx +92 -0
- package/src/panels/events/event-replay.tsx +80 -0
- package/src/panels/events/events-panel.tsx +414 -0
- package/src/panels/events/events-tab.tsx +212 -0
- package/src/panels/events/lock-list.tsx +54 -0
- package/src/panels/events/locks-tab.tsx +103 -0
- package/src/panels/events/mcl-replay.tsx +77 -0
- package/src/panels/events/mcl-tab.tsx +83 -0
- package/src/panels/events/operations-tab-wrapper.tsx +62 -0
- package/src/panels/events/operations-tab.tsx +41 -0
- package/src/panels/events/replay-tab.tsx +76 -0
- package/src/panels/events/secrets-audit.tsx +64 -0
- package/src/panels/events/secrets-tab.tsx +75 -0
- package/src/panels/events/subscription-list.tsx +54 -0
- package/src/panels/events/subscriptions-tab.tsx +82 -0
- package/src/panels/files/file-aspects.tsx +93 -0
- package/src/panels/files/file-editor.tsx +160 -0
- package/src/panels/files/file-explorer-keybindings.ts +468 -0
- package/src/panels/files/file-explorer-panel.tsx +545 -0
- package/src/panels/files/file-lineage.tsx +163 -0
- package/src/panels/files/file-list-item.tsx +28 -0
- package/src/panels/files/file-metadata.tsx +62 -0
- package/src/panels/files/file-preview.tsx +108 -0
- package/src/panels/files/file-schema.tsx +89 -0
- package/src/panels/files/file-tree-node.tsx +44 -0
- package/src/panels/files/file-tree.tsx +169 -0
- package/src/panels/files/share-links-tab.tsx +33 -0
- package/src/panels/files/uploads-tab.tsx +45 -0
- package/src/panels/payments/approval-list.tsx +83 -0
- package/src/panels/payments/balance-card.tsx +43 -0
- package/src/panels/payments/budget-card.tsx +70 -0
- package/src/panels/payments/payments-panel.tsx +451 -0
- package/src/panels/payments/policy-list.tsx +64 -0
- package/src/panels/payments/reservation-list.tsx +78 -0
- package/src/panels/payments/transaction-list.tsx +103 -0
- package/src/panels/payments/transfer-form.tsx +109 -0
- package/src/panels/search/column-search.tsx +79 -0
- package/src/panels/search/knowledge-view.tsx +100 -0
- package/src/panels/search/memory-list.tsx +197 -0
- package/src/panels/search/playbook-list.tsx +77 -0
- package/src/panels/search/rlm-answer-view.tsx +105 -0
- package/src/panels/search/search-panel.tsx +405 -0
- package/src/panels/search/search-results.tsx +116 -0
- package/src/panels/stack/stack-panel.tsx +474 -0
- package/src/panels/versions/conflicts-tab.tsx +59 -0
- package/src/panels/versions/entry-detail.tsx +89 -0
- package/src/panels/versions/transaction-actions.tsx +34 -0
- package/src/panels/versions/transaction-list.tsx +90 -0
- package/src/panels/versions/versions-panel.tsx +276 -0
- package/src/panels/workflows/execution-list.tsx +102 -0
- package/src/panels/workflows/scheduler-view.tsx +135 -0
- package/src/panels/workflows/workflow-list.tsx +88 -0
- package/src/panels/workflows/workflows-panel.tsx +295 -0
- package/src/panels/zones/brick-detail.tsx +136 -0
- package/src/panels/zones/brick-list.tsx +56 -0
- package/src/panels/zones/cache-tab.tsx +118 -0
- package/src/panels/zones/drift-view.tsx +97 -0
- package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
- package/src/panels/zones/memories-tab.tsx +37 -0
- package/src/panels/zones/reindex-status.tsx +84 -0
- package/src/panels/zones/workspaces-tab.tsx +37 -0
- package/src/panels/zones/zone-list.tsx +73 -0
- package/src/panels/zones/zones-panel.tsx +559 -0
- package/src/services/command-runner.ts +303 -0
- package/src/shared/accessibility-announcements.ts +44 -0
- package/src/shared/action-registry.ts +466 -0
- package/src/shared/brick-states.ts +91 -0
- package/src/shared/command-palette.ts +35 -0
- package/src/shared/components/announcement-bar.tsx +30 -0
- package/src/shared/components/app-confirm-dialog.tsx +29 -0
- package/src/shared/components/breadcrumb.tsx +21 -0
- package/src/shared/components/brick-gate.tsx +60 -0
- package/src/shared/components/command-output.tsx +95 -0
- package/src/shared/components/command-palette.tsx +97 -0
- package/src/shared/components/confirm-dialog.tsx +61 -0
- package/src/shared/components/diff-viewer.tsx +219 -0
- package/src/shared/components/empty-state.tsx +36 -0
- package/src/shared/components/error-bar.tsx +60 -0
- package/src/shared/components/error-boundary.tsx +53 -0
- package/src/shared/components/help-overlay.tsx +99 -0
- package/src/shared/components/identity-switcher.tsx +168 -0
- package/src/shared/components/loading-indicator.tsx +40 -0
- package/src/shared/components/pagination-bar.tsx +68 -0
- package/src/shared/components/pre-connection-screen.tsx +398 -0
- package/src/shared/components/scroll-indicator.tsx +46 -0
- package/src/shared/components/side-nav-utils.ts +68 -0
- package/src/shared/components/side-nav.tsx +287 -0
- package/src/shared/components/spinner.tsx +26 -0
- package/src/shared/components/status-bar.tsx +117 -0
- package/src/shared/components/styled-text.tsx +72 -0
- package/src/shared/components/sub-tab-bar-utils.ts +100 -0
- package/src/shared/components/sub-tab-bar.tsx +40 -0
- package/src/shared/components/tab-bar-utils.ts +36 -0
- package/src/shared/components/tab-bar.tsx +50 -0
- package/src/shared/components/text-input.tsx +73 -0
- package/src/shared/components/tooltip.tsx +53 -0
- package/src/shared/components/virtual-list.tsx +93 -0
- package/src/shared/components/welcome-screen.tsx +111 -0
- package/src/shared/hooks/use-api.ts +10 -0
- package/src/shared/hooks/use-brick-available.ts +42 -0
- package/src/shared/hooks/use-confirm.ts +66 -0
- package/src/shared/hooks/use-connection-state.ts +67 -0
- package/src/shared/hooks/use-copy.ts +31 -0
- package/src/shared/hooks/use-fresh-server.ts +62 -0
- package/src/shared/hooks/use-keyboard.ts +58 -0
- package/src/shared/hooks/use-list-navigation.ts +106 -0
- package/src/shared/hooks/use-swr.ts +117 -0
- package/src/shared/hooks/use-tab-fallback.ts +32 -0
- package/src/shared/hooks/use-text-input.ts +113 -0
- package/src/shared/hooks/use-visible-tabs.ts +61 -0
- package/src/shared/lib/circular-buffer.ts +82 -0
- package/src/shared/lib/clipboard.ts +14 -0
- package/src/shared/nav-items.ts +73 -0
- package/src/shared/navigation.ts +110 -0
- package/src/shared/status-breadcrumb.ts +74 -0
- package/src/shared/syntax-style.ts +3 -0
- package/src/shared/tab-visibility.ts +15 -0
- package/src/shared/text-style.ts +23 -0
- package/src/shared/theme.ts +179 -0
- package/src/shared/utils/format-size.ts +20 -0
- package/src/shared/utils/format-text.ts +10 -0
- package/src/shared/utils/format-time.ts +72 -0
- package/src/shared/utils/lru-cache.ts +75 -0
- package/src/stores/access-store-types.ts +154 -0
- package/src/stores/access-store.ts +674 -0
- package/src/stores/agents-store.ts +404 -0
- package/src/stores/announcement-store.ts +46 -0
- package/src/stores/api-console-store.ts +476 -0
- package/src/stores/connectors-store.ts +434 -0
- package/src/stores/create-api-action.ts +140 -0
- package/src/stores/delegation-store.ts +300 -0
- package/src/stores/error-store.ts +102 -0
- package/src/stores/events-store.ts +163 -0
- package/src/stores/files-store.ts +630 -0
- package/src/stores/first-run-store.ts +34 -0
- package/src/stores/global-store.ts +255 -0
- package/src/stores/infra-store.ts +461 -0
- package/src/stores/knowledge-store.ts +358 -0
- package/src/stores/lineage-store.ts +126 -0
- package/src/stores/mcp-store.ts +147 -0
- package/src/stores/payments-store.ts +545 -0
- package/src/stores/search-store-types.ts +155 -0
- package/src/stores/search-store.ts +656 -0
- package/src/stores/share-link-store.ts +151 -0
- package/src/stores/stack-store.ts +352 -0
- package/src/stores/ui-store.ts +161 -0
- package/src/stores/upload-store.ts +131 -0
- package/src/stores/versions-store.ts +355 -0
- package/src/stores/workflows-store.ts +402 -0
- package/src/stores/workspace-store.ts +185 -0
- package/src/stores/zones-store.ts +378 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store for share link management (via JSON-RPC).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { create } from "zustand";
|
|
6
|
+
import type { FetchClient } from "@nexus-ai-fs/api-client";
|
|
7
|
+
import { createApiAction, categorizeError } from "./create-api-action.js";
|
|
8
|
+
import { useErrorStore } from "./error-store.js";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface ShareLink {
|
|
15
|
+
readonly link_id: string;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly permission_level: string;
|
|
18
|
+
readonly status: string;
|
|
19
|
+
readonly access_count: number;
|
|
20
|
+
readonly has_password: boolean;
|
|
21
|
+
readonly expires_at: string | null;
|
|
22
|
+
readonly created_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ShareLinkAccessLog {
|
|
26
|
+
readonly timestamp: string;
|
|
27
|
+
readonly ip_address: string;
|
|
28
|
+
readonly user_agent: string;
|
|
29
|
+
readonly success: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Store
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export interface ShareLinkState {
|
|
37
|
+
readonly links: readonly ShareLink[];
|
|
38
|
+
readonly linksLoading: boolean;
|
|
39
|
+
readonly selectedLinkIndex: number;
|
|
40
|
+
readonly accessLogs: readonly ShareLinkAccessLog[];
|
|
41
|
+
readonly accessLogsLoading: boolean;
|
|
42
|
+
readonly error: string | null;
|
|
43
|
+
|
|
44
|
+
readonly fetchLinks: (client: FetchClient, options?: { includeRevoked?: boolean; includeExpired?: boolean }) => Promise<void>;
|
|
45
|
+
readonly createLink: (params: { path: string; permission_level: string; password?: string; expires_in_hours?: number; max_access_count?: number }, client: FetchClient) => Promise<void>;
|
|
46
|
+
readonly revokeLink: (linkId: string, client: FetchClient) => Promise<void>;
|
|
47
|
+
readonly fetchAccessLogs: (linkId: string, client: FetchClient) => Promise<void>;
|
|
48
|
+
readonly setSelectedLinkIndex: (index: number) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SOURCE = "files";
|
|
52
|
+
|
|
53
|
+
export const useShareLinkStore = create<ShareLinkState>((set, get) => ({
|
|
54
|
+
links: [],
|
|
55
|
+
linksLoading: false,
|
|
56
|
+
selectedLinkIndex: 0,
|
|
57
|
+
accessLogs: [],
|
|
58
|
+
accessLogsLoading: false,
|
|
59
|
+
error: null,
|
|
60
|
+
|
|
61
|
+
// =========================================================================
|
|
62
|
+
// Actions with loading keys — createApiAction
|
|
63
|
+
// =========================================================================
|
|
64
|
+
|
|
65
|
+
fetchLinks: createApiAction<ShareLinkState, [FetchClient, ({ includeRevoked?: boolean; includeExpired?: boolean } | undefined)?]>(set, {
|
|
66
|
+
loadingKey: "linksLoading",
|
|
67
|
+
source: SOURCE,
|
|
68
|
+
errorMessage: "Failed to fetch share links",
|
|
69
|
+
action: async (client, options) => {
|
|
70
|
+
const response = await client.post<{ result: readonly ShareLink[] }>(
|
|
71
|
+
"/api/nfs/list_share_links",
|
|
72
|
+
{
|
|
73
|
+
params: {
|
|
74
|
+
path: "",
|
|
75
|
+
include_revoked: options?.includeRevoked ?? false,
|
|
76
|
+
include_expired: options?.includeExpired ?? false,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
return {
|
|
81
|
+
links: Array.isArray(response?.result) ? response.result : [],
|
|
82
|
+
selectedLinkIndex: 0,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
fetchAccessLogs: createApiAction<ShareLinkState, [string, FetchClient]>(set, {
|
|
88
|
+
loadingKey: "accessLogsLoading",
|
|
89
|
+
source: SOURCE,
|
|
90
|
+
errorMessage: "Failed to fetch access logs",
|
|
91
|
+
action: async (linkId, client) => {
|
|
92
|
+
const response = await client.post<{ result: readonly ShareLinkAccessLog[] }>(
|
|
93
|
+
"/api/nfs/get_share_link_access_logs",
|
|
94
|
+
{
|
|
95
|
+
params: { link_id: linkId },
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
accessLogs: response.result ?? [],
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// Actions without loading keys — inline with error store integration
|
|
106
|
+
// =========================================================================
|
|
107
|
+
|
|
108
|
+
createLink: async (params, client) => {
|
|
109
|
+
set({ error: null });
|
|
110
|
+
try {
|
|
111
|
+
await client.post<{ result: ShareLink }>(
|
|
112
|
+
"/api/nfs/create_share_link",
|
|
113
|
+
{
|
|
114
|
+
params: {
|
|
115
|
+
path: params.path,
|
|
116
|
+
permission_level: params.permission_level,
|
|
117
|
+
password: params.password,
|
|
118
|
+
expires_in_hours: params.expires_in_hours,
|
|
119
|
+
max_access_count: params.max_access_count,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
await get().fetchLinks(client);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const message = err instanceof Error ? err.message : "Failed to create share link";
|
|
126
|
+
set({ error: message });
|
|
127
|
+
useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
revokeLink: async (linkId, client) => {
|
|
132
|
+
set({ error: null });
|
|
133
|
+
try {
|
|
134
|
+
await client.post<{ result: unknown }>(
|
|
135
|
+
"/api/nfs/revoke_share_link",
|
|
136
|
+
{
|
|
137
|
+
params: { link_id: linkId },
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
await get().fetchLinks(client);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const message = err instanceof Error ? err.message : "Failed to revoke share link";
|
|
143
|
+
set({ error: message });
|
|
144
|
+
useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
setSelectedLinkIndex: (index) => {
|
|
149
|
+
set({ selectedLinkIndex: index });
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zustand store for the Stack panel: Docker containers, nexus.yaml config,
|
|
3
|
+
* .state.json runtime state, and server health details.
|
|
4
|
+
*
|
|
5
|
+
* Reads local files via Bun.file() and runs `docker compose ps` via Bun.spawn().
|
|
6
|
+
* Server health is fetched via the FetchClient from the global store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { create } from "zustand";
|
|
10
|
+
import type { FetchClient } from "@nexus-ai-fs/api-client";
|
|
11
|
+
import { useErrorStore } from "./error-store.js";
|
|
12
|
+
import { categorizeError } from "./create-api-action.js";
|
|
13
|
+
import { useUiStore } from "./ui-store.js";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export interface ContainerInfo {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly service: string;
|
|
22
|
+
readonly state: string;
|
|
23
|
+
readonly health: string;
|
|
24
|
+
readonly ports: string;
|
|
25
|
+
readonly image: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HealthComponent {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly status: string;
|
|
31
|
+
readonly detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DetailedHealth {
|
|
35
|
+
readonly status: string;
|
|
36
|
+
readonly service: string;
|
|
37
|
+
readonly components: Record<string, { status: string; detail?: string }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type StackTab = "containers" | "config" | "state";
|
|
41
|
+
|
|
42
|
+
/** Paths to config/state files used by the stack. */
|
|
43
|
+
export interface StackPaths {
|
|
44
|
+
readonly projectRoot: string;
|
|
45
|
+
readonly nexusYaml: string;
|
|
46
|
+
readonly stateJson: string;
|
|
47
|
+
readonly composeFile: string;
|
|
48
|
+
readonly dataDir: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StackState {
|
|
52
|
+
// Tabs
|
|
53
|
+
readonly activeTab: StackTab;
|
|
54
|
+
|
|
55
|
+
// Docker containers
|
|
56
|
+
readonly containers: readonly ContainerInfo[];
|
|
57
|
+
readonly containersLoading: boolean;
|
|
58
|
+
|
|
59
|
+
// nexus.yaml raw content
|
|
60
|
+
readonly configYaml: string;
|
|
61
|
+
readonly configLoading: boolean;
|
|
62
|
+
|
|
63
|
+
// .state.json parsed
|
|
64
|
+
readonly stateJson: Record<string, unknown> | null;
|
|
65
|
+
readonly stateLoading: boolean;
|
|
66
|
+
|
|
67
|
+
// Server health details
|
|
68
|
+
readonly healthDetails: DetailedHealth | null;
|
|
69
|
+
readonly healthLoading: boolean;
|
|
70
|
+
|
|
71
|
+
// File paths
|
|
72
|
+
readonly paths: StackPaths | null;
|
|
73
|
+
|
|
74
|
+
// General
|
|
75
|
+
readonly error: string | null;
|
|
76
|
+
readonly lastRefreshed: number;
|
|
77
|
+
|
|
78
|
+
// Actions
|
|
79
|
+
readonly setActiveTab: (tab: StackTab) => void;
|
|
80
|
+
readonly fetchContainers: () => Promise<void>;
|
|
81
|
+
readonly fetchConfig: () => Promise<void>;
|
|
82
|
+
readonly fetchState: () => Promise<void>;
|
|
83
|
+
readonly fetchHealth: (client: FetchClient) => Promise<void>;
|
|
84
|
+
readonly refreshAll: (client: FetchClient | null) => Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// Helpers
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the project root by walking up from CWD looking for nexus.yaml.
|
|
93
|
+
* Walks up to 20 levels to handle git worktrees nested inside the main repo
|
|
94
|
+
* (e.g. .claude/worktrees/<name>/packages/nexus-tui/).
|
|
95
|
+
* Returns CWD if not found (commands will just fail gracefully).
|
|
96
|
+
*/
|
|
97
|
+
function findProjectRoot(): string {
|
|
98
|
+
const path = require("node:path");
|
|
99
|
+
const fs = require("node:fs");
|
|
100
|
+
let dir = process.cwd();
|
|
101
|
+
for (let i = 0; i < 20; i++) {
|
|
102
|
+
if (fs.existsSync(path.join(dir, "nexus.yaml"))) return dir;
|
|
103
|
+
const parent = path.dirname(dir);
|
|
104
|
+
if (parent === dir) break;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
return process.cwd();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deduplicate port mappings from Docker Publishers array.
|
|
112
|
+
* Docker lists each mapping twice (IPv4 0.0.0.0 + IPv6 ::).
|
|
113
|
+
* Only show published (host-mapped) ports, skip unexposed ones.
|
|
114
|
+
*/
|
|
115
|
+
function formatPorts(publishers: { URL?: string; PublishedPort?: number; TargetPort?: number }[]): string {
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
const parts: string[] = [];
|
|
118
|
+
for (const p of publishers) {
|
|
119
|
+
if (!p.PublishedPort) continue; // skip unexposed ports
|
|
120
|
+
const key = `${p.PublishedPort}->${p.TargetPort}`;
|
|
121
|
+
if (seen.has(key)) continue;
|
|
122
|
+
seen.add(key);
|
|
123
|
+
parts.push(key);
|
|
124
|
+
}
|
|
125
|
+
return parts.join(", ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function containerFromObj(obj: Record<string, unknown>): ContainerInfo {
|
|
129
|
+
const publishers = obj.Publishers;
|
|
130
|
+
return {
|
|
131
|
+
name: (obj.Name ?? obj.Names ?? "") as string,
|
|
132
|
+
service: (obj.Service ?? "") as string,
|
|
133
|
+
state: (obj.State ?? "") as string,
|
|
134
|
+
health: (obj.Health ?? "") as string,
|
|
135
|
+
ports: Array.isArray(publishers)
|
|
136
|
+
? formatPorts(publishers as { URL?: string; PublishedPort?: number; TargetPort?: number }[])
|
|
137
|
+
: (obj.Ports ?? "") as string,
|
|
138
|
+
image: (obj.Image ?? "") as string,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse `docker compose ps --format json` output.
|
|
144
|
+
* Handles both formats:
|
|
145
|
+
* - NDJSON (one JSON object per line) — newer Compose versions
|
|
146
|
+
* - JSON array (single `[...]` blob) — older Compose versions
|
|
147
|
+
*/
|
|
148
|
+
function parseDockerPs(output: string): ContainerInfo[] {
|
|
149
|
+
const trimmed = output.trim();
|
|
150
|
+
if (!trimmed) return [];
|
|
151
|
+
|
|
152
|
+
// Try JSON array first (older Compose: single [...] output)
|
|
153
|
+
if (trimmed.startsWith("[")) {
|
|
154
|
+
try {
|
|
155
|
+
const arr = JSON.parse(trimmed);
|
|
156
|
+
if (Array.isArray(arr)) {
|
|
157
|
+
return arr.map(containerFromObj);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Fall through to NDJSON parsing
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// NDJSON: one JSON object per line (newer Compose)
|
|
165
|
+
const containers: ContainerInfo[] = [];
|
|
166
|
+
for (const line of trimmed.split("\n")) {
|
|
167
|
+
const l = line.trim();
|
|
168
|
+
if (!l || !l.startsWith("{")) continue;
|
|
169
|
+
try {
|
|
170
|
+
containers.push(containerFromObj(JSON.parse(l)));
|
|
171
|
+
} catch {
|
|
172
|
+
// Skip non-JSON lines
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return containers;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// =============================================================================
|
|
179
|
+
// Store
|
|
180
|
+
// =============================================================================
|
|
181
|
+
|
|
182
|
+
export const useStackStore = create<StackState>((set, get) => ({
|
|
183
|
+
activeTab: "containers",
|
|
184
|
+
containers: [],
|
|
185
|
+
containersLoading: false,
|
|
186
|
+
configYaml: "",
|
|
187
|
+
configLoading: false,
|
|
188
|
+
stateJson: null,
|
|
189
|
+
stateLoading: false,
|
|
190
|
+
healthDetails: null,
|
|
191
|
+
healthLoading: false,
|
|
192
|
+
paths: null,
|
|
193
|
+
error: null,
|
|
194
|
+
lastRefreshed: 0,
|
|
195
|
+
|
|
196
|
+
setActiveTab: (tab) => set({ activeTab: tab }),
|
|
197
|
+
|
|
198
|
+
fetchContainers: async () => {
|
|
199
|
+
set({ containersLoading: true, error: null });
|
|
200
|
+
try {
|
|
201
|
+
const projectRoot = findProjectRoot();
|
|
202
|
+
const proc = Bun.spawn(
|
|
203
|
+
["docker", "compose", "ps", "--format", "json", "-a"],
|
|
204
|
+
{
|
|
205
|
+
cwd: projectRoot,
|
|
206
|
+
stdout: "pipe",
|
|
207
|
+
stderr: "pipe",
|
|
208
|
+
env: { ...process.env },
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
213
|
+
new Response(proc.stdout).text(),
|
|
214
|
+
new Response(proc.stderr).text(),
|
|
215
|
+
proc.exited,
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
if (exitCode !== 0) {
|
|
219
|
+
const errMsg = stderr.trim() || `docker compose ps exited with code ${exitCode}`;
|
|
220
|
+
set({ containers: [], containersLoading: false, error: errMsg });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const containers = parseDockerPs(stdout);
|
|
225
|
+
set({ containers, containersLoading: false });
|
|
226
|
+
useUiStore.getState().markDataUpdated("stack");
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const message = err instanceof Error ? err.message : "Failed to query Docker";
|
|
229
|
+
set({ containersLoading: false, error: message });
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
fetchConfig: async () => {
|
|
234
|
+
set({ configLoading: true });
|
|
235
|
+
try {
|
|
236
|
+
const projectRoot = findProjectRoot();
|
|
237
|
+
const path = require("node:path");
|
|
238
|
+
const fs = require("node:fs");
|
|
239
|
+
const yamlPath = path.join(projectRoot, "nexus.yaml");
|
|
240
|
+
const file = Bun.file(yamlPath);
|
|
241
|
+
const exists = await file.exists();
|
|
242
|
+
if (exists) {
|
|
243
|
+
const text = await file.text();
|
|
244
|
+
|
|
245
|
+
// Resolve all file paths from the config
|
|
246
|
+
let dataDir = path.join(projectRoot, "nexus-data");
|
|
247
|
+
let composeFile = path.join(projectRoot, "nexus-stack.yml");
|
|
248
|
+
const dataDirMatch = text.match(/^data_dir:\s*(.+)$/m);
|
|
249
|
+
if (dataDirMatch?.[1]) {
|
|
250
|
+
const parsed = dataDirMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
251
|
+
dataDir = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
|
|
252
|
+
}
|
|
253
|
+
const composeMatch = text.match(/^compose_file:\s*(.+)$/m);
|
|
254
|
+
if (composeMatch?.[1]) {
|
|
255
|
+
const parsed = composeMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
256
|
+
composeFile = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
set({
|
|
260
|
+
configYaml: text,
|
|
261
|
+
configLoading: false,
|
|
262
|
+
paths: {
|
|
263
|
+
projectRoot,
|
|
264
|
+
nexusYaml: yamlPath,
|
|
265
|
+
stateJson: path.join(dataDir, ".state.json"),
|
|
266
|
+
composeFile,
|
|
267
|
+
dataDir,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
set({ configYaml: "", configLoading: false, paths: null });
|
|
272
|
+
}
|
|
273
|
+
useUiStore.getState().markDataUpdated("stack");
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const message = err instanceof Error ? err.message : "Failed to read nexus.yaml";
|
|
276
|
+
set({ configYaml: `Error: ${message}`, configLoading: false });
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
fetchState: async () => {
|
|
281
|
+
set({ stateLoading: true });
|
|
282
|
+
try {
|
|
283
|
+
const projectRoot = findProjectRoot();
|
|
284
|
+
const path = require("node:path");
|
|
285
|
+
const fs = require("node:fs");
|
|
286
|
+
|
|
287
|
+
// Read nexus.yaml to find data_dir
|
|
288
|
+
let dataDir = path.join(projectRoot, "nexus-data");
|
|
289
|
+
try {
|
|
290
|
+
const yaml = fs.readFileSync(path.join(projectRoot, "nexus.yaml"), "utf-8") as string;
|
|
291
|
+
const match = yaml.match(/^data_dir:\s*(.+)$/m);
|
|
292
|
+
if (match?.[1]) {
|
|
293
|
+
const parsed = match[1].trim().replace(/^["']|["']$/g, "");
|
|
294
|
+
dataDir = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Use default
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const stateFile = Bun.file(path.join(dataDir, ".state.json"));
|
|
301
|
+
const exists = await stateFile.exists();
|
|
302
|
+
if (exists) {
|
|
303
|
+
const text = await stateFile.text();
|
|
304
|
+
const parsed = JSON.parse(text);
|
|
305
|
+
set({ stateJson: parsed, stateLoading: false });
|
|
306
|
+
} else {
|
|
307
|
+
set({ stateJson: null, stateLoading: false });
|
|
308
|
+
}
|
|
309
|
+
useUiStore.getState().markDataUpdated("stack");
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const message = err instanceof Error ? err.message : "Failed to read .state.json";
|
|
312
|
+
set({ stateJson: null, stateLoading: false, error: message });
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
fetchHealth: async (client: FetchClient) => {
|
|
317
|
+
set({ healthLoading: true });
|
|
318
|
+
try {
|
|
319
|
+
const health = await client.get<DetailedHealth>("/health/detailed");
|
|
320
|
+
set({ healthDetails: health, healthLoading: false });
|
|
321
|
+
useUiStore.getState().markDataUpdated("stack");
|
|
322
|
+
} catch {
|
|
323
|
+
// Fall back to basic health
|
|
324
|
+
try {
|
|
325
|
+
const basic = await client.get<{ status: string; service: string }>("/health");
|
|
326
|
+
set({
|
|
327
|
+
healthDetails: { status: basic.status, service: basic.service, components: {} },
|
|
328
|
+
healthLoading: false,
|
|
329
|
+
});
|
|
330
|
+
useUiStore.getState().markDataUpdated("stack");
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const message = err instanceof Error ? err.message : "Health check failed";
|
|
333
|
+
set({ healthDetails: null, healthLoading: false });
|
|
334
|
+
useErrorStore.getState().pushError({ message, category: categorizeError(message), source: "stack" });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
refreshAll: async (client) => {
|
|
340
|
+
const { fetchContainers, fetchConfig, fetchState, fetchHealth } = get();
|
|
341
|
+
const promises: Promise<void>[] = [
|
|
342
|
+
fetchContainers(),
|
|
343
|
+
fetchConfig(),
|
|
344
|
+
fetchState(),
|
|
345
|
+
];
|
|
346
|
+
if (client) {
|
|
347
|
+
promises.push(fetchHealth(client));
|
|
348
|
+
}
|
|
349
|
+
await Promise.allSettled(promises);
|
|
350
|
+
set({ lastRefreshed: Date.now() });
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting UI state store.
|
|
3
|
+
*
|
|
4
|
+
* Owns state that spans multiple panels: focus pane, zoom, scroll positions.
|
|
5
|
+
* Keeps domain stores (files-store, agents-store, etc.) free of UI chrome.
|
|
6
|
+
*
|
|
7
|
+
* @see Issue #3066 Architecture Decision 2A
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { create } from "zustand";
|
|
11
|
+
import type { PanelId } from "./global-store.js";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
export type FocusPane = "left" | "right";
|
|
18
|
+
|
|
19
|
+
export interface UiState {
|
|
20
|
+
/** Per-panel focus pane for split views. */
|
|
21
|
+
readonly focusPane: Readonly<Record<string, FocusPane>>;
|
|
22
|
+
|
|
23
|
+
/** Panel currently in fullscreen zoom mode, or null. */
|
|
24
|
+
readonly zoomedPanel: PanelId | null;
|
|
25
|
+
|
|
26
|
+
/** Persisted scroll positions keyed by "panel:list" identifier. */
|
|
27
|
+
readonly scrollPositions: Readonly<Record<string, number>>;
|
|
28
|
+
|
|
29
|
+
/** True when a global overlay (welcome, help, identity switcher) is active. */
|
|
30
|
+
readonly overlayActive: boolean;
|
|
31
|
+
|
|
32
|
+
/** True when a full-screen panel-level editor is open (suppresses global keybindings). */
|
|
33
|
+
readonly fileEditorOpen: boolean;
|
|
34
|
+
|
|
35
|
+
/** Whether the side navigation bar is visible (toggled via Ctrl+B). */
|
|
36
|
+
readonly sideNavVisible: boolean;
|
|
37
|
+
|
|
38
|
+
/** Timestamp (ms) of the last successful data update per panel. 0 = never fetched. */
|
|
39
|
+
readonly panelDataTimestamps: Readonly<Partial<Record<PanelId, number>>>;
|
|
40
|
+
|
|
41
|
+
/** Timestamp (ms) of the last time the user visited each panel. 0 = never visited. */
|
|
42
|
+
readonly panelVisitTimestamps: Readonly<Partial<Record<PanelId, number>>>;
|
|
43
|
+
|
|
44
|
+
/** Mirror of global-store activePanel, kept in sync by markPanelVisited. */
|
|
45
|
+
readonly activePanelId: PanelId;
|
|
46
|
+
|
|
47
|
+
// Actions
|
|
48
|
+
readonly setFocusPane: (panel: string, pane: FocusPane) => void;
|
|
49
|
+
readonly toggleFocusPane: (panel: string) => void;
|
|
50
|
+
readonly getFocusPane: (panel: string) => FocusPane;
|
|
51
|
+
readonly toggleZoom: (panel: PanelId) => void;
|
|
52
|
+
readonly clearZoom: () => void;
|
|
53
|
+
readonly setScrollPosition: (key: string, position: number) => void;
|
|
54
|
+
readonly getScrollPosition: (key: string) => number;
|
|
55
|
+
readonly setOverlayActive: (active: boolean) => void;
|
|
56
|
+
readonly setFileEditorOpen: (open: boolean) => void;
|
|
57
|
+
readonly toggleSideNav: () => void;
|
|
58
|
+
readonly setSideNavVisible: (visible: boolean) => void;
|
|
59
|
+
readonly markDataUpdated: (panel: PanelId) => void;
|
|
60
|
+
readonly markPanelVisited: (panel: PanelId) => void;
|
|
61
|
+
readonly resetFreshnessTimestamps: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Store
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
export const useUiStore = create<UiState>((set, get) => ({
|
|
69
|
+
focusPane: {},
|
|
70
|
+
zoomedPanel: null,
|
|
71
|
+
scrollPositions: {},
|
|
72
|
+
overlayActive: false,
|
|
73
|
+
fileEditorOpen: false,
|
|
74
|
+
sideNavVisible: true,
|
|
75
|
+
panelDataTimestamps: {},
|
|
76
|
+
// "files" is the default active panel — mark it visited at startup so data
|
|
77
|
+
// fetched during the initial load does not produce a false-positive unseen dot.
|
|
78
|
+
panelVisitTimestamps: { files: Date.now() },
|
|
79
|
+
activePanelId: "files" as PanelId,
|
|
80
|
+
|
|
81
|
+
setFocusPane: (panel, pane) => {
|
|
82
|
+
set((state) => ({
|
|
83
|
+
focusPane: { ...state.focusPane, [panel]: pane },
|
|
84
|
+
}));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
toggleFocusPane: (panel) => {
|
|
88
|
+
const current = get().focusPane[panel];
|
|
89
|
+
const next: FocusPane = current === "left" ? "right" : current === "right" ? "left" : "right";
|
|
90
|
+
set((state) => ({
|
|
91
|
+
focusPane: { ...state.focusPane, [panel]: next },
|
|
92
|
+
}));
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getFocusPane: (panel) => {
|
|
96
|
+
return get().focusPane[panel] ?? "left";
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
toggleZoom: (panel) => {
|
|
100
|
+
set((state) => ({
|
|
101
|
+
zoomedPanel: state.zoomedPanel === panel ? null : panel,
|
|
102
|
+
}));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
clearZoom: () => {
|
|
106
|
+
set({ zoomedPanel: null });
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
setScrollPosition: (key, position) => {
|
|
110
|
+
set((state) => ({
|
|
111
|
+
scrollPositions: { ...state.scrollPositions, [key]: position },
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
getScrollPosition: (key) => {
|
|
116
|
+
return get().scrollPositions[key] ?? 0;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
setOverlayActive: (active) => {
|
|
120
|
+
set({ overlayActive: active });
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
setFileEditorOpen: (open) => {
|
|
124
|
+
set({ fileEditorOpen: open });
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
toggleSideNav: () => {
|
|
128
|
+
set((state) => ({ sideNavVisible: !state.sideNavVisible }));
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
setSideNavVisible: (visible) => {
|
|
132
|
+
set({ sideNavVisible: visible });
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
markDataUpdated: (panel) => {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
// If the user is currently viewing this panel, also update the visit
|
|
138
|
+
// timestamp so the panel is not marked "unseen" when the user leaves.
|
|
139
|
+
const isActive = get().activePanelId === panel;
|
|
140
|
+
set((state) => ({
|
|
141
|
+
panelDataTimestamps: { ...state.panelDataTimestamps, [panel]: now },
|
|
142
|
+
...(isActive
|
|
143
|
+
? { panelVisitTimestamps: { ...state.panelVisitTimestamps, [panel]: now } }
|
|
144
|
+
: {}),
|
|
145
|
+
}));
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
markPanelVisited: (panel) => {
|
|
149
|
+
set((state) => ({
|
|
150
|
+
panelVisitTimestamps: { ...state.panelVisitTimestamps, [panel]: Date.now() },
|
|
151
|
+
activePanelId: panel,
|
|
152
|
+
}));
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
resetFreshnessTimestamps: () => {
|
|
156
|
+
set({
|
|
157
|
+
panelDataTimestamps: {},
|
|
158
|
+
panelVisitTimestamps: { [get().activePanelId]: Date.now() },
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
}));
|