feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs (#21252)
Split out of #21240 — all remaining app-dev improvements. Stacked on #21251 (review/merge that first). - Actionable recovery hints on failed syncs; unified diff renderer; `--dry-run` guard. - Return `flatEntity` on update/delete sync actions and unify the diff label. - Summarize the dev-mode entity list unless `--verbose`. - Docs: syncing & recovery guide + dry-run + open-an-issue prompt. - Live execution mode for synced logic functions; clearer manifest warnings. <img width="1018" height="700" alt="image" src="https://github.com/user-attachments/assets/5e9ce19e-0f1d-4f99-8524-4e118bde932b" />
This commit is contained in:
@@ -42,7 +42,7 @@ export class DevModeOrchestrator {
|
||||
private startWatchersStep: StartWatchersOrchestratorStep;
|
||||
|
||||
constructor(options: DevModeOrchestratorOptions) {
|
||||
this.debounceMs = options.debounceMs ?? 2_000;
|
||||
this.debounceMs = options.debounceMs ?? 1_000;
|
||||
this.state = options.state;
|
||||
this.verbose = options.verbose ?? false;
|
||||
|
||||
|
||||
+8
-2
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
|
||||
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type SyncApplicationOrchestratorStepOutput = {
|
||||
@@ -95,11 +95,17 @@ export class SyncApplicationOrchestratorStep {
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
message: `Sync failed with error: ${serializeError(syncResult.error)}`,
|
||||
message: `Sync failed with error: ${syncResult.message ?? 'Sync failed'}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryHint = getSyncErrorRecoveryHint(syncResult.message);
|
||||
|
||||
if (recoveryHint) {
|
||||
events.push({ message: recoveryHint, status: 'info' });
|
||||
}
|
||||
|
||||
const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed';
|
||||
|
||||
step.output = { syncStatus: 'error', error: summaryMessage };
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type OrchestratorStateEntityInfo } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { summarizeEntityStatuses } from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
|
||||
const entity = (
|
||||
name: string,
|
||||
status: OrchestratorStateEntityInfo['status'],
|
||||
): OrchestratorStateEntityInfo => ({ name, path: name, status });
|
||||
|
||||
describe('summarizeEntityStatuses', () => {
|
||||
it('counts every status in display order', () => {
|
||||
const parts = summarizeEntityStatuses([
|
||||
entity('a', 'success'),
|
||||
entity('b', 'success'),
|
||||
entity('c', 'error'),
|
||||
entity('d', 'building'),
|
||||
]);
|
||||
|
||||
expect(parts).toEqual([
|
||||
{ status: 'success', count: 2, label: 'synced' },
|
||||
{ status: 'building', count: 1, label: 'building' },
|
||||
{ status: 'error', count: 1, label: 'error' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only the non-zero statuses', () => {
|
||||
const parts = summarizeEntityStatuses([
|
||||
entity('a', 'success'),
|
||||
entity('b', 'success'),
|
||||
]);
|
||||
|
||||
expect(parts).toEqual([{ status: 'success', count: 2, label: 'synced' }]);
|
||||
});
|
||||
});
|
||||
+13
-6
@@ -15,6 +15,7 @@ import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import {
|
||||
DevUiEntitySection,
|
||||
DevUiEntitySummary,
|
||||
ENTITY_ORDER,
|
||||
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
|
||||
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
|
||||
@@ -62,8 +63,10 @@ export const DevUiStepStatusLabel = ({
|
||||
|
||||
export const DevUiApplicationPanel = ({
|
||||
state,
|
||||
verbose = false,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
verbose?: boolean;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
const groupedEntities = groupEntitiesByType(state.entities);
|
||||
@@ -111,13 +114,17 @@ export const DevUiApplicationPanel = ({
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
{verbose ? (
|
||||
ENTITY_ORDER.map((type) => {
|
||||
const entities = groupedEntities.get(type) ?? [];
|
||||
|
||||
return (
|
||||
<DevUiEntitySection key={type} type={type} entities={entities} />
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<DevUiEntitySection key={type} type={type} entities={entities} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DevUiEntitySummary entities={Array.from(state.entities.values())} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UPLOAD_FRAMES,
|
||||
mapFileStatusToDevUiStatus,
|
||||
shortenPath,
|
||||
summarizeEntityStatuses,
|
||||
} from '@/cli/utilities/dev/ui/dev-ui-constants';
|
||||
import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
|
||||
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
@@ -67,6 +68,35 @@ export const DevUiEntitySection = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntitySummary = ({
|
||||
entities,
|
||||
}: {
|
||||
entities: OrchestratorStateEntityInfo[];
|
||||
}): React.ReactElement | null => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
const parts = summarizeEntityStatuses(entities);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
Entities{' '}
|
||||
</Text>
|
||||
{parts.map((part, index) => (
|
||||
<Box key={part.status}>
|
||||
{index > 0 && <Text dimColor> · </Text>}
|
||||
<DevUiStatusIcon uiStatus={mapFileStatusToDevUiStatus(part.status)} />
|
||||
<Text>
|
||||
{part.count} {part.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevUiEntityLegend = (): React.ReactElement => {
|
||||
const { Box, Text } = useInk();
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ const SETTLE_DELAY_MS = 80;
|
||||
|
||||
const DevUI = ({
|
||||
uiStateManager,
|
||||
verbose,
|
||||
}: {
|
||||
uiStateManager: DevUiStateManager;
|
||||
verbose: boolean;
|
||||
}): React.ReactElement => {
|
||||
const { Box, Static } = useInk();
|
||||
|
||||
@@ -85,8 +87,8 @@ const DevUI = ({
|
||||
</Static>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<DevUiApplicationPanel state={state} />
|
||||
<DevUiEntityLegend />
|
||||
<DevUiApplicationPanel state={state} verbose={verbose} />
|
||||
{verbose && <DevUiEntityLegend />}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -94,15 +96,15 @@ const DevUI = ({
|
||||
|
||||
export const renderDevUI = async (
|
||||
uiStateManager: DevUiStateManager,
|
||||
verbose = false,
|
||||
): Promise<{ unmount: () => void }> => {
|
||||
const ink = await import('ink');
|
||||
const { render, Box, Text, Static } = ink;
|
||||
|
||||
const { unmount } = render(
|
||||
<InkProvider value={{ Box, Text, Static }}>
|
||||
<DevUI uiStateManager={uiStateManager} />
|
||||
<DevUI uiStateManager={uiStateManager} verbose={verbose} />
|
||||
</InkProvider>,
|
||||
{ incrementalRendering: true },
|
||||
);
|
||||
|
||||
return { unmount };
|
||||
|
||||
@@ -78,20 +78,9 @@ export const SYNC_STATUS_LABELS: Record<OrchestratorStateSyncStatus, string> = {
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
export const SPINNER_FRAMES = [
|
||||
'⠋',
|
||||
'⠙',
|
||||
'⠹',
|
||||
'⠸',
|
||||
'⠼',
|
||||
'⠴',
|
||||
'⠦',
|
||||
'⠧',
|
||||
'⠇',
|
||||
'⠏',
|
||||
];
|
||||
export const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
|
||||
|
||||
export const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒'];
|
||||
export const UPLOAD_FRAMES = ['↑', '⇡', '↑', '⇡'];
|
||||
|
||||
export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.Object]: 'Objects',
|
||||
@@ -158,6 +147,43 @@ export const groupEntitiesByType = (
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export type DevUiEntityStatusSummaryPart = {
|
||||
status: OrchestratorStateFileStatus;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ENTITY_STATUS_SUMMARY_ORDER: {
|
||||
status: OrchestratorStateFileStatus;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ status: 'success', label: 'synced' },
|
||||
{ status: 'building', label: 'building' },
|
||||
{ status: 'uploading', label: 'uploading' },
|
||||
{ status: 'pending', label: 'pending' },
|
||||
{ status: 'error', label: 'error' },
|
||||
];
|
||||
|
||||
export const summarizeEntityStatuses = (
|
||||
entities: OrchestratorStateEntityInfo[],
|
||||
): DevUiEntityStatusSummaryPart[] => {
|
||||
const counts: Record<OrchestratorStateFileStatus, number> = {
|
||||
pending: 0,
|
||||
building: 0,
|
||||
uploading: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
counts[entity.status] += 1;
|
||||
}
|
||||
|
||||
return ENTITY_STATUS_SUMMARY_ORDER.filter(
|
||||
({ status }) => counts[status] > 0,
|
||||
).map(({ status, label }) => ({ status, count: counts[status], label }));
|
||||
};
|
||||
|
||||
export const getApplicationUrl = (state: OrchestratorState): string | null => {
|
||||
const applicationId = state.steps.resolveApplication.output.applicationId;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user