@nextclaw/ui 0.12.6 → 0.12.8

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.
Files changed (115) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/assets/{ChannelsList-D8p4OlM6.js → ChannelsList-KIQIxluX.js} +1 -1
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-BMxf9CIK.js} +1 -1
  4. package/dist/assets/DocBrowser-CyDgAtO9.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-Ce28gRXt.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-o92MOA2L.js} +1 -1
  7. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-BySqkYDh.js} +1 -1
  8. package/dist/assets/MarketplacePage-C0olZaek.js +1 -0
  9. package/dist/assets/{McpMarketplacePage-CxPFOgxv.js → McpMarketplacePage-DqKaiXO9.js} +1 -1
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-IrmzoslW.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-CmTIzgI7.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-8_Kalfwl.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-DyYVWsyK.js → RemoteAccessPage-CyQlSjPf.js} +1 -1
  14. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +1 -0
  15. package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-DNBR-UbE.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-Ba1RPJaG.js} +1 -1
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-Doqp5ghH.js} +1 -1
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-DniXoIN5.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.js → book-open-DocgeQtR.js} +1 -1
  20. package/dist/assets/chat-page-Bph8M5zo.js +58 -0
  21. package/dist/assets/chat-session-display-CoN3Wmn-.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-BvKvh1R8.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-CVqPF5ie.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-Bop2oB18.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-DVv8taGY.js} +1 -1
  26. package/dist/assets/desktop-update-config-1KBrqLBC.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-Da5Gm_pO.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-DmAlInRu.js} +1 -1
  29. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-DFjw3x1B.js} +1 -1
  30. package/dist/assets/{hash-DaFBEkmi.js → hash-DJtaCejM.js} +1 -1
  31. package/dist/assets/i18n-CwHZ-9vt.js +1 -0
  32. package/dist/assets/{index-CE4N7ItL.css → index-DafCdM4F.css} +1 -1
  33. package/dist/assets/{index-riX7Sg0_.js → index-DdksE6U3.js} +3 -3
  34. package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-DHSEQ3OH.js} +1 -1
  35. package/dist/assets/loader-circle-PsSP0H9n.js +1 -0
  36. package/dist/assets/{logos-Dzlz30M3.js → logos-DEFUIR12.js} +1 -1
  37. package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-Da3i3r6G.js} +1 -1
  38. package/dist/assets/play-DBQbBxTA.js +1 -0
  39. package/dist/assets/plus-DUOVbsyQ.js +1 -0
  40. package/dist/assets/{popover-BSXxm5bj.js → popover-C_mWOFzI.js} +1 -1
  41. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-D6HkNtfz.js} +1 -1
  42. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-DRcvRrnc.js} +1 -1
  43. package/dist/assets/rotate-cw-BmDKfXtH.js +1 -0
  44. package/dist/assets/{save-Us9fg4Sj.js → save-DHGmi2e9.js} +1 -1
  45. package/dist/assets/search-MChQRYR1.js +1 -0
  46. package/dist/assets/{security-config-BGWYwxNr.js → security-config-CbXfPZzr.js} +1 -1
  47. package/dist/assets/{select-DLYqySQK.js → select-Caud8QvU.js} +1 -1
  48. package/dist/assets/skeleton-B-4vRq_Z.js +1 -0
  49. package/dist/assets/{status-dot-DGayudyB.js → status-dot-DurKKSwA.js} +1 -1
  50. package/dist/assets/{switch-Dz2ScsKx.js → switch-0rmPBRKI.js} +1 -1
  51. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-5JLVL6v8.js} +1 -1
  52. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-C6caKPoz.js} +1 -1
  53. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-dwnaa_qi.js} +1 -1
  54. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-mMeWD_yo.js} +1 -1
  55. package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-BmxxvCNf.js} +1 -1
  56. package/dist/assets/x-DuMhMATD.js +1 -0
  57. package/dist/index.html +20 -20
  58. package/package.json +6 -6
  59. package/src/api/runtime-control.ts +34 -0
  60. package/src/api/runtime-control.types.ts +58 -0
  61. package/src/api/types.ts +13 -0
  62. package/src/{App.test.tsx → app.test.tsx} +1 -1
  63. package/src/{App.tsx → app.tsx} +1 -1
  64. package/src/components/chat/ChatConversationPanel.test.tsx +78 -16
  65. package/src/components/chat/ChatSidebar.test.tsx +36 -7
  66. package/src/components/chat/ChatSidebar.tsx +19 -26
  67. package/src/components/chat/chat-child-session-panel.tsx +16 -8
  68. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  69. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  70. package/src/components/chat/managers/chat-session-list.manager.test.ts +82 -31
  71. package/src/components/chat/managers/chat-session-list.manager.ts +79 -14
  72. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  73. package/src/components/chat/ncp/README.md +1 -1
  74. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  75. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  76. package/src/components/chat/ncp/ncp-session-adapter.test.ts +5 -1
  77. package/src/components/chat/ncp/ncp-session-adapter.ts +12 -0
  78. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  79. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  80. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  81. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  82. package/src/components/config/ModelConfig.test.tsx +108 -2
  83. package/src/components/config/RuntimeConfig.tsx +14 -6
  84. package/src/components/config/desktop-update-config.test.tsx +85 -0
  85. package/src/components/config/desktop-update-config.tsx +44 -3
  86. package/src/components/config/runtime-control-card.test.tsx +255 -0
  87. package/src/components/config/runtime-control-card.tsx +301 -0
  88. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  89. package/src/components/config/runtime-presence-card.tsx +163 -0
  90. package/src/desktop/desktop-update.types.ts +25 -0
  91. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  92. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  93. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  94. package/src/desktop/stores/desktop-update.store.ts +7 -1
  95. package/src/hooks/use-runtime-control.ts +24 -0
  96. package/src/lib/desktop-update-labels.utils.ts +28 -2
  97. package/src/lib/i18n.runtime-control.ts +120 -0
  98. package/src/lib/i18n.ts +2 -4
  99. package/src/main.tsx +1 -1
  100. package/src/runtime-control/runtime-control.manager.ts +118 -0
  101. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  102. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  103. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  104. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  105. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  106. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  107. package/dist/assets/i18n-C3jb83S6.js +0 -1
  108. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  109. package/dist/assets/plus-CIXME2pD.js +0 -1
  110. package/dist/assets/search-B_Qr0f6C.js +0 -1
  111. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  112. package/dist/assets/x-B8Tho_xC.js +0 -1
  113. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BZoDjXye.js} +0 -0
  114. /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-DmlGaay2.js} +0 -0
  115. /package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +0 -0
@@ -0,0 +1,255 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import type { ReactNode } from 'react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { toast } from 'sonner';
7
+ import { RuntimeControlCard } from '@/components/config/runtime-control-card';
8
+ import { setLanguage } from '@/lib/i18n';
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ useRuntimeControl: vi.fn(),
12
+ useRuntimeServiceAction: vi.fn(),
13
+ waitForRecovery: vi.fn(),
14
+ restartApp: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('sonner', () => ({
18
+ toast: {
19
+ success: vi.fn(),
20
+ error: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ vi.mock('@/hooks/use-runtime-control', () => ({
25
+ useRuntimeControl: (...args: unknown[]) => mocks.useRuntimeControl(...args),
26
+ useRuntimeServiceAction: (...args: unknown[]) => mocks.useRuntimeServiceAction(...args),
27
+ }));
28
+
29
+ vi.mock('@/runtime-control/runtime-control.manager', () => ({
30
+ runtimeControlManager: {
31
+ waitForRecovery: (...args: unknown[]) => mocks.waitForRecovery(...args),
32
+ restartApp: (...args: unknown[]) => mocks.restartApp(...args),
33
+ },
34
+ }));
35
+
36
+ function createWrapper(queryClient: QueryClient) {
37
+ return function Wrapper({ children }: { children: ReactNode }) {
38
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
39
+ };
40
+ }
41
+
42
+ describe('RuntimeControlCard', () => {
43
+ beforeEach(() => {
44
+ setLanguage('zh');
45
+ vi.clearAllMocks();
46
+ mocks.useRuntimeControl.mockReturnValue({
47
+ data: {
48
+ environment: 'managed-local-service',
49
+ lifecycle: 'healthy',
50
+ serviceState: 'running',
51
+ message: 'runtime healthy',
52
+ canStartService: {
53
+ available: false,
54
+ requiresConfirmation: false,
55
+ impact: 'brief-ui-disconnect',
56
+ reasonIfUnavailable: '当前页面已经由运行中的本地服务托管。'
57
+ },
58
+ canRestartService: {
59
+ available: true,
60
+ requiresConfirmation: false,
61
+ impact: 'brief-ui-disconnect',
62
+ },
63
+ canStopService: {
64
+ available: true,
65
+ requiresConfirmation: true,
66
+ impact: 'brief-ui-disconnect',
67
+ },
68
+ canRestartApp: {
69
+ available: false,
70
+ requiresConfirmation: true,
71
+ impact: 'full-app-relaunch',
72
+ reasonIfUnavailable: 'desktop only',
73
+ },
74
+ managementHint: 'This page is served by the running local service.'
75
+ },
76
+ isError: false,
77
+ error: null,
78
+ });
79
+ mocks.useRuntimeServiceAction.mockReturnValue({
80
+ mutateAsync: vi.fn().mockResolvedValue({
81
+ accepted: true,
82
+ action: 'restart-service',
83
+ lifecycle: 'restarting-service',
84
+ message: 'Restart scheduled. This page may disconnect for a few seconds.',
85
+ }),
86
+ isPending: false,
87
+ });
88
+ mocks.waitForRecovery.mockResolvedValue({
89
+ environment: 'managed-local-service',
90
+ lifecycle: 'healthy',
91
+ serviceState: 'running',
92
+ message: 'runtime healthy',
93
+ canStartService: {
94
+ available: false,
95
+ requiresConfirmation: false,
96
+ impact: 'brief-ui-disconnect',
97
+ reasonIfUnavailable: '当前页面已经由运行中的本地服务托管。'
98
+ },
99
+ canRestartService: {
100
+ available: true,
101
+ requiresConfirmation: false,
102
+ impact: 'brief-ui-disconnect',
103
+ },
104
+ canStopService: {
105
+ available: true,
106
+ requiresConfirmation: true,
107
+ impact: 'brief-ui-disconnect',
108
+ },
109
+ canRestartApp: {
110
+ available: false,
111
+ requiresConfirmation: true,
112
+ impact: 'full-app-relaunch',
113
+ reasonIfUnavailable: 'desktop only',
114
+ },
115
+ managementHint: 'This page is served by the running local service.'
116
+ });
117
+ mocks.restartApp.mockResolvedValue({
118
+ accepted: true,
119
+ action: 'restart-app',
120
+ lifecycle: 'restarting-app',
121
+ message: 'NextClaw app restart scheduled.',
122
+ });
123
+ });
124
+
125
+ afterEach(() => {
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it('renders service management actions from the current capability view', () => {
130
+ const queryClient = new QueryClient();
131
+
132
+ render(<RuntimeControlCard />, {
133
+ wrapper: createWrapper(queryClient),
134
+ });
135
+
136
+ const startButton = screen.getByRole('button', { name: '启动服务' }) as HTMLButtonElement;
137
+ const restartAppButton = screen.getByRole('button', { name: '重启应用' }) as HTMLButtonElement;
138
+ expect(screen.getByText('服务管理')).toBeTruthy();
139
+ expect(screen.getByText('服务运行中')).toBeTruthy();
140
+ expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
141
+ expect(screen.getByRole('button', { name: '停止服务' })).toBeTruthy();
142
+ expect(startButton.disabled).toBe(true);
143
+ expect(restartAppButton.disabled).toBe(true);
144
+ expect(screen.getByText('desktop only')).toBeTruthy();
145
+ });
146
+
147
+ it('runs the restart service flow and waits for recovery', async () => {
148
+ const queryClient = new QueryClient();
149
+ const user = userEvent.setup();
150
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
151
+ const mutateAsync = vi.fn().mockResolvedValue({
152
+ accepted: true,
153
+ action: 'restart-service',
154
+ lifecycle: 'restarting-service',
155
+ message: 'Restart scheduled. This page may disconnect for a few seconds.',
156
+ });
157
+ mocks.useRuntimeServiceAction.mockReturnValue({
158
+ mutateAsync,
159
+ isPending: false,
160
+ });
161
+
162
+ render(<RuntimeControlCard />, {
163
+ wrapper: createWrapper(queryClient),
164
+ });
165
+
166
+ await user.click(screen.getByRole('button', { name: '重启服务' }));
167
+
168
+ await waitFor(() => {
169
+ expect(mutateAsync).toHaveBeenCalledWith('restart-service');
170
+ expect(mocks.waitForRecovery).toHaveBeenCalledTimes(1);
171
+ });
172
+ expect(toast.success).toHaveBeenCalledWith('Restart scheduled. This page may disconnect for a few seconds.');
173
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['runtime-control'] });
174
+ });
175
+
176
+ it('runs the stop service flow after confirmation', async () => {
177
+ const queryClient = new QueryClient();
178
+ const user = userEvent.setup();
179
+ const mutateAsync = vi.fn().mockResolvedValue({
180
+ accepted: true,
181
+ action: 'stop-service',
182
+ lifecycle: 'stopping-service',
183
+ message: 'Stop scheduled. This page will disconnect shortly.',
184
+ });
185
+ mocks.useRuntimeServiceAction.mockReturnValue({
186
+ mutateAsync,
187
+ isPending: false,
188
+ });
189
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
190
+
191
+ render(<RuntimeControlCard />, {
192
+ wrapper: createWrapper(queryClient),
193
+ });
194
+
195
+ await user.click(screen.getByRole('button', { name: '停止服务' }));
196
+
197
+ await waitFor(() => {
198
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
199
+ expect(mutateAsync).toHaveBeenCalledWith('stop-service');
200
+ });
201
+ expect(mocks.waitForRecovery).not.toHaveBeenCalled();
202
+ expect(toast.success).toHaveBeenCalledWith('Stop scheduled. This page will disconnect shortly.');
203
+ });
204
+
205
+ it('runs the desktop restart app flow after confirmation', async () => {
206
+ const queryClient = new QueryClient();
207
+ const user = userEvent.setup();
208
+
209
+ mocks.useRuntimeControl.mockReturnValue({
210
+ data: {
211
+ environment: 'desktop-embedded',
212
+ lifecycle: 'healthy',
213
+ serviceState: 'running',
214
+ message: 'runtime healthy',
215
+ canStartService: {
216
+ available: false,
217
+ requiresConfirmation: false,
218
+ impact: 'none',
219
+ },
220
+ canRestartService: {
221
+ available: true,
222
+ requiresConfirmation: false,
223
+ impact: 'brief-ui-disconnect',
224
+ },
225
+ canStopService: {
226
+ available: false,
227
+ requiresConfirmation: true,
228
+ impact: 'brief-ui-disconnect',
229
+ },
230
+ canRestartApp: {
231
+ available: true,
232
+ requiresConfirmation: true,
233
+ impact: 'full-app-relaunch',
234
+ },
235
+ managementHint: 'desktop launcher hint'
236
+ },
237
+ isError: false,
238
+ error: null,
239
+ });
240
+
241
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
242
+
243
+ render(<RuntimeControlCard />, {
244
+ wrapper: createWrapper(queryClient),
245
+ });
246
+
247
+ await user.click(screen.getByRole('button', { name: '重启应用' }));
248
+
249
+ await waitFor(() => {
250
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
251
+ expect(mocks.restartApp).toHaveBeenCalledTimes(1);
252
+ });
253
+ expect(toast.success).toHaveBeenCalledWith('NextClaw app restart scheduled.');
254
+ });
255
+ });
@@ -0,0 +1,301 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import type {
4
+ RuntimeActionCapability,
5
+ RuntimeControlAction,
6
+ RuntimeControlView,
7
+ RuntimeLifecycleState,
8
+ RuntimeServiceState
9
+ } from '@/api/runtime-control.types';
10
+ import { Button } from '@/components/ui/button';
11
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
12
+ import { useRuntimeControl, useRuntimeServiceAction } from '@/hooks/use-runtime-control';
13
+ import { t } from '@/lib/i18n';
14
+ import { runtimeControlManager } from '@/runtime-control/runtime-control.manager';
15
+ import { Loader2, RotateCw, Square, Play } from 'lucide-react';
16
+ import { toast } from 'sonner';
17
+
18
+ type VisibleRuntimeAction = {
19
+ action: RuntimeControlAction;
20
+ capability: RuntimeActionCapability;
21
+ label: string;
22
+ icon: 'play' | 'rotate' | 'square';
23
+ variant?: 'default' | 'secondary' | 'destructive';
24
+ };
25
+
26
+ function resolveLifecycleLabel(lifecycle: RuntimeLifecycleState): string {
27
+ if (lifecycle === 'healthy') {
28
+ return t('runtimeControlHealthy');
29
+ }
30
+ if (lifecycle === 'starting-service') {
31
+ return t('runtimeControlStartingService');
32
+ }
33
+ if (lifecycle === 'restarting-service') {
34
+ return t('runtimeControlRestartingService');
35
+ }
36
+ if (lifecycle === 'stopping-service') {
37
+ return t('runtimeControlStoppingService');
38
+ }
39
+ if (lifecycle === 'restarting-app') {
40
+ return t('runtimeControlRestartingApp');
41
+ }
42
+ if (lifecycle === 'recovering') {
43
+ return t('runtimeControlRecovering');
44
+ }
45
+ if (lifecycle === 'failed') {
46
+ return t('runtimeControlFailed');
47
+ }
48
+ return t('runtimeControlUnavailable');
49
+ }
50
+
51
+ function resolveServiceStateLabel(serviceState: RuntimeServiceState): string {
52
+ if (serviceState === 'running') {
53
+ return t('runtimeControlServiceRunning');
54
+ }
55
+ if (serviceState === 'stopped') {
56
+ return t('runtimeControlServiceStopped');
57
+ }
58
+ if (serviceState === 'starting') {
59
+ return t('runtimeControlServiceStarting');
60
+ }
61
+ if (serviceState === 'stopping') {
62
+ return t('runtimeControlServiceStopping');
63
+ }
64
+ if (serviceState === 'restarting') {
65
+ return t('runtimeControlServiceRestarting');
66
+ }
67
+ return t('runtimeControlServiceUnknown');
68
+ }
69
+
70
+ function resolveEnvironmentLabel(view: RuntimeControlView): string {
71
+ if (view.environment === 'desktop-embedded') {
72
+ return t('runtimeControlEnvironmentDesktop');
73
+ }
74
+ if (view.environment === 'managed-local-service') {
75
+ return t('runtimeControlEnvironmentManagedService');
76
+ }
77
+ if (view.environment === 'self-hosted-web') {
78
+ return t('runtimeControlEnvironmentSelfHosted');
79
+ }
80
+ return t('runtimeControlEnvironmentSharedWeb');
81
+ }
82
+
83
+ function resolveVisibleActions(controlView: RuntimeControlView | undefined): VisibleRuntimeAction[] {
84
+ if (!controlView) {
85
+ return [];
86
+ }
87
+
88
+ const actions: VisibleRuntimeAction[] = [
89
+ {
90
+ action: 'start-service',
91
+ capability: controlView.canStartService,
92
+ label: t('runtimeControlStartService'),
93
+ icon: 'play'
94
+ },
95
+ {
96
+ action: 'restart-service',
97
+ capability: controlView.canRestartService,
98
+ label: t('runtimeControlRestartService'),
99
+ icon: 'rotate'
100
+ },
101
+ {
102
+ action: 'stop-service',
103
+ capability: controlView.canStopService,
104
+ label: t('runtimeControlStopService'),
105
+ icon: 'square',
106
+ variant: 'destructive'
107
+ },
108
+ {
109
+ action: 'restart-app',
110
+ capability: controlView.canRestartApp,
111
+ label: t('runtimeControlRestartApp'),
112
+ icon: 'rotate',
113
+ variant: 'secondary'
114
+ }
115
+ ];
116
+
117
+ return actions.filter((item) => item.capability.available || Boolean(item.capability.reasonIfUnavailable));
118
+ }
119
+
120
+ function resolveActionHelp(action: RuntimeControlAction): string {
121
+ if (action === 'start-service') {
122
+ return t('runtimeControlStartingServiceHelp');
123
+ }
124
+ if (action === 'restart-service') {
125
+ return t('runtimeControlRestartingServiceHelp');
126
+ }
127
+ if (action === 'stop-service') {
128
+ return t('runtimeControlStoppingServiceHelp');
129
+ }
130
+ return t('runtimeControlRestartingAppHelp');
131
+ }
132
+
133
+ function RuntimeActionIcon(props: { icon: VisibleRuntimeAction['icon']; busy: boolean }) {
134
+ const { busy, icon } = props;
135
+ if (busy) {
136
+ return <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
137
+ }
138
+ if (icon === 'play') {
139
+ return <Play className="mr-2 h-4 w-4" />;
140
+ }
141
+ if (icon === 'square') {
142
+ return <Square className="mr-2 h-4 w-4" />;
143
+ }
144
+ return <RotateCw className="mr-2 h-4 w-4" />;
145
+ }
146
+
147
+ export function RuntimeControlCard() {
148
+ const queryClient = useQueryClient();
149
+ const runtimeControlQuery = useRuntimeControl();
150
+ const serviceActionMutation = useRuntimeServiceAction();
151
+ const [localLifecycle, setLocalLifecycle] = useState<RuntimeLifecycleState | null>(null);
152
+ const [localServiceState, setLocalServiceState] = useState<RuntimeServiceState | null>(null);
153
+ const [localMessage, setLocalMessage] = useState<string | null>(null);
154
+ const [busyAction, setBusyAction] = useState<RuntimeControlAction | null>(null);
155
+
156
+ const controlView = runtimeControlQuery.data;
157
+ const displayedLifecycle = localLifecycle ?? controlView?.lifecycle ?? 'healthy';
158
+ const displayedServiceState = localServiceState ?? controlView?.serviceState ?? 'unknown';
159
+ const displayedMessage = localMessage ?? controlView?.message ?? t('runtimeControlDescription');
160
+ const busy = serviceActionMutation.isPending || busyAction !== null || displayedLifecycle === 'recovering';
161
+ const visibleActions = resolveVisibleActions(controlView);
162
+
163
+ const resetLocalState = () => {
164
+ setLocalLifecycle(null);
165
+ setLocalServiceState(null);
166
+ setLocalMessage(null);
167
+ setBusyAction(null);
168
+ };
169
+
170
+ const handleServiceAction = async (action: Extract<RuntimeControlAction, 'start-service' | 'restart-service' | 'stop-service'>) => {
171
+ const capability = action === 'start-service'
172
+ ? controlView?.canStartService
173
+ : action === 'stop-service'
174
+ ? controlView?.canStopService
175
+ : controlView?.canRestartService;
176
+
177
+ if (!capability?.available) {
178
+ toast.error(capability?.reasonIfUnavailable ?? t('runtimeControlLoadFailed'));
179
+ return;
180
+ }
181
+ if (action === 'stop-service' && capability.requiresConfirmation && !window.confirm(t('runtimeControlStopServiceConfirm'))) {
182
+ return;
183
+ }
184
+
185
+ setBusyAction(action);
186
+ setLocalLifecycle(action === 'start-service' ? 'starting-service' : action === 'stop-service' ? 'stopping-service' : 'restarting-service');
187
+ setLocalServiceState(action === 'start-service' ? 'starting' : action === 'stop-service' ? 'stopping' : 'restarting');
188
+ setLocalMessage(resolveActionHelp(action));
189
+
190
+ try {
191
+ const result = await serviceActionMutation.mutateAsync(action);
192
+ toast.success(result.message);
193
+ if (action === 'stop-service') {
194
+ return;
195
+ }
196
+ setLocalLifecycle('recovering');
197
+ setLocalMessage(t('runtimeControlRecoveringHelp'));
198
+ const recoveredView = await runtimeControlManager.waitForRecovery();
199
+ queryClient.setQueryData(['runtime-control'], recoveredView);
200
+ await queryClient.invalidateQueries({ queryKey: ['runtime-control'] });
201
+ resetLocalState();
202
+ toast.success(t('runtimeControlRecovered'));
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : t('runtimeControlActionFailed');
205
+ setLocalLifecycle('failed');
206
+ setLocalServiceState(action === 'stop-service' ? 'running' : 'unknown');
207
+ setLocalMessage(message);
208
+ setBusyAction(null);
209
+ toast.error(`${t('runtimeControlActionFailed')}: ${message}`);
210
+ }
211
+ };
212
+
213
+ const handleRestartApp = async () => {
214
+ if (!controlView?.canRestartApp.available) {
215
+ toast.error(controlView?.canRestartApp.reasonIfUnavailable ?? t('runtimeRestartAppUnavailable'));
216
+ return;
217
+ }
218
+ if (!window.confirm(t('runtimeControlRestartAppConfirm'))) {
219
+ return;
220
+ }
221
+
222
+ setBusyAction('restart-app');
223
+ setLocalLifecycle('restarting-app');
224
+ setLocalMessage(t('runtimeControlRestartingAppHelp'));
225
+
226
+ try {
227
+ const result = await runtimeControlManager.restartApp();
228
+ toast.success(result.message);
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : t('runtimeControlActionFailed');
231
+ setLocalLifecycle('failed');
232
+ setLocalMessage(message);
233
+ setBusyAction(null);
234
+ toast.error(`${t('runtimeControlActionFailed')}: ${message}`);
235
+ }
236
+ };
237
+
238
+ return (
239
+ <Card>
240
+ <CardHeader>
241
+ <CardTitle>{t('runtimeControlTitle')}</CardTitle>
242
+ <CardDescription>{t('runtimeControlDescription')}</CardDescription>
243
+ </CardHeader>
244
+ <CardContent className="space-y-4">
245
+ <div className="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3">
246
+ <div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
247
+ <div className="text-sm font-medium text-gray-900">{resolveServiceStateLabel(displayedServiceState)}</div>
248
+ <div className="text-xs text-gray-500">
249
+ {controlView ? resolveEnvironmentLabel(controlView) : t('runtimeControlLoading')}
250
+ </div>
251
+ </div>
252
+ <p className="text-sm text-gray-600">{displayedMessage}</p>
253
+ <div className="text-xs text-gray-500">{resolveLifecycleLabel(displayedLifecycle)}</div>
254
+ {controlView?.managementHint ? (
255
+ <p className="text-xs text-gray-500">{controlView.managementHint}</p>
256
+ ) : null}
257
+ {runtimeControlQuery.isError && !busy ? (
258
+ <p className="text-sm text-amber-700">
259
+ {runtimeControlQuery.error instanceof Error ? runtimeControlQuery.error.message : t('runtimeControlLoadFailed')}
260
+ </p>
261
+ ) : null}
262
+ </div>
263
+
264
+ <div className="flex flex-col gap-3 md:flex-row md:flex-wrap">
265
+ {visibleActions.map((item) => {
266
+ const isBusyAction = busyAction === item.action;
267
+ const disabled = !item.capability.available || busy;
268
+ const handleClick = () => {
269
+ if (item.action === 'restart-app') {
270
+ void handleRestartApp();
271
+ return;
272
+ }
273
+ void handleServiceAction(item.action);
274
+ };
275
+
276
+ return (
277
+ <Button
278
+ key={item.action}
279
+ type="button"
280
+ variant={item.variant ?? 'default'}
281
+ onClick={handleClick}
282
+ disabled={disabled}
283
+ >
284
+ <RuntimeActionIcon icon={item.icon} busy={isBusyAction} />
285
+ {item.label}
286
+ </Button>
287
+ );
288
+ })}
289
+ </div>
290
+
291
+ {visibleActions
292
+ .filter((item) => !item.capability.available && item.capability.reasonIfUnavailable)
293
+ .map((item) => (
294
+ <p key={`${item.action}-reason`} className="text-xs text-gray-500">
295
+ {item.capability.reasonIfUnavailable}
296
+ </p>
297
+ ))}
298
+ </CardContent>
299
+ </Card>
300
+ );
301
+ }