Improve app errors logs at sync (#19174)
1. Fix scrollbar Before https://github.com/user-attachments/assets/29792a71-b2dd-49f6-bb90-9d15feeb95aa After https://github.com/user-attachments/assets/939a000a-b787-4ea5-a9f0-61fbac886025 2. Introduce verbose vs non-verbose verbose = what we have today (very detailed) non-verbose = summarized (with a log to say add --verbose for full logs!) without --verbose <img width="1256" height="876" alt="updated_non_verbose" src="https://github.com/user-attachments/assets/d6194c41-2366-4297-a7ac-b3f3b27e08dd" /> with --verbose <img width="422" height="819" alt="verbose_logs" src="https://github.com/user-attachments/assets/409e2e88-ec3d-4bab-957c-ef319895f8c5" />
This commit is contained in:
@@ -271,6 +271,17 @@ export class OrchestratorState {
|
||||
});
|
||||
}
|
||||
|
||||
for (const [filePath, syncableEntity] of entityTypeMap) {
|
||||
if (!entities.has(filePath)) {
|
||||
entities.set(filePath, {
|
||||
name: filePath,
|
||||
path: filePath,
|
||||
type: syncableEntity,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
|
||||
export type DevModeOrchestratorOptions = {
|
||||
state: OrchestratorState;
|
||||
debounceMs?: number;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export class DevModeOrchestrator {
|
||||
@@ -30,6 +31,7 @@ export class DevModeOrchestrator {
|
||||
|
||||
private apiService: ApiService;
|
||||
private clientService: ClientService;
|
||||
private verbose: boolean;
|
||||
private skipTypecheck = true;
|
||||
private checkServerStep: CheckServerOrchestratorStep;
|
||||
private buildManifestStep: BuildManifestOrchestratorStep;
|
||||
@@ -42,6 +44,7 @@ export class DevModeOrchestrator {
|
||||
constructor(options: DevModeOrchestratorOptions) {
|
||||
this.debounceMs = options.debounceMs ?? 200;
|
||||
this.state = options.state;
|
||||
this.verbose = options.verbose ?? false;
|
||||
|
||||
this.apiService = new ApiService({ disableInterceptors: true });
|
||||
const apiService = this.apiService;
|
||||
@@ -59,7 +62,10 @@ export class DevModeOrchestrator {
|
||||
apiService,
|
||||
configService,
|
||||
});
|
||||
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
|
||||
this.uploadFilesStep = new UploadFilesOrchestratorStep({
|
||||
...stepDeps,
|
||||
verbose: this.verbose,
|
||||
});
|
||||
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
|
||||
...stepDeps,
|
||||
clientService: this.clientService,
|
||||
@@ -68,12 +74,14 @@ export class DevModeOrchestrator {
|
||||
this.syncApplicationStep = new SyncApplicationOrchestratorStep({
|
||||
...stepDeps,
|
||||
apiService,
|
||||
verbose: this.verbose,
|
||||
});
|
||||
this.startWatchersStep = new StartWatchersOrchestratorStep({
|
||||
...stepDeps,
|
||||
scheduleSync: this.scheduleSync.bind(this),
|
||||
onFileBuilt: this.handleFileBuilt.bind(this),
|
||||
shouldSkipTypecheck: () => this.skipTypecheck,
|
||||
verbose: this.verbose,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +91,14 @@ export class DevModeOrchestrator {
|
||||
await ensureDir(outputDir);
|
||||
await emptyDir(outputDir);
|
||||
|
||||
if (!this.verbose) {
|
||||
this.state.addEvent({
|
||||
message: 'Add --verbose to see fully detailed logs',
|
||||
status: 'info',
|
||||
});
|
||||
this.state.notify();
|
||||
}
|
||||
|
||||
await this.startWatchersStep.start();
|
||||
|
||||
this.serverCheckInterval = setInterval(() => {
|
||||
@@ -160,6 +176,8 @@ export class DevModeOrchestrator {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.steps.ensureValidTokens.status = 'done';
|
||||
|
||||
const buildResult = await this.buildManifestStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
@@ -190,6 +208,10 @@ export class DevModeOrchestrator {
|
||||
appPath: this.state.appPath,
|
||||
});
|
||||
|
||||
if (this.state.steps.syncApplication.status === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectsOrFieldsChanged) {
|
||||
await this.generateApiClientStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
|
||||
|
||||
describe('formatSyncErrorEvents', () => {
|
||||
it('should return null for null input', () => {
|
||||
expect(formatSyncErrorEvents(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined input', () => {
|
||||
expect(formatSyncErrorEvents(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object input', () => {
|
||||
expect(formatSyncErrorEvents('string error')).toBeNull();
|
||||
expect(formatSyncErrorEvents(42)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions is missing', () => {
|
||||
expect(formatSyncErrorEvents({ message: 'error' })).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions.errors is missing', () => {
|
||||
expect(
|
||||
formatSyncErrorEvents({
|
||||
extensions: { summary: { totalErrors: 1 } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when extensions.summary is missing', () => {
|
||||
expect(
|
||||
formatSyncErrorEvents({
|
||||
extensions: { errors: {} },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should format a single error', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_NAME',
|
||||
message: 'Field name is invalid',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events?.[0]).toEqual({
|
||||
message: 'Sync failed with 1 error',
|
||||
status: 'error',
|
||||
});
|
||||
expect(events?.[1]).toEqual({
|
||||
message: 'fieldMetadata: 1 error',
|
||||
status: 'error',
|
||||
});
|
||||
expect(events?.[2].message).toContain('INVALID_NAME');
|
||||
expect(events?.[2].message).toContain('Field name is invalid');
|
||||
expect(events?.[2].message).toContain('field-uuid-1');
|
||||
});
|
||||
|
||||
it('should format multiple errors across metadata types', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'First error' },
|
||||
{ code: 'ERR_2', message: 'Second error' },
|
||||
],
|
||||
},
|
||||
],
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR_3', message: 'Third error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 2, objectMetadata: 1, totalErrors: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events?.[0].message).toBe('Sync failed with 3 errors');
|
||||
expect(events?.[1].message).toBe('fieldMetadata: 2 errors');
|
||||
expect(events?.[2].message).toContain('1. ERR_1');
|
||||
expect(events?.[3].message).toContain('2. ERR_2');
|
||||
expect(events?.[4].message).toBe('objectMetadata: 1 error');
|
||||
expect(events?.[5].message).toContain('1. ERR_3');
|
||||
});
|
||||
|
||||
it('should format errors with details for both objectMetadata and fieldMetadata', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'obj-uuid-1',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'DUPLICATE_NAME',
|
||||
message: 'An object with this name already exists',
|
||||
value: 'postCard',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fieldMetadata: [
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-1',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_TYPE',
|
||||
message: 'Field type is not supported',
|
||||
value: 'UNKNOWN_TYPE',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: 'field-uuid-2',
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
code: 'MISSING_RELATION_TARGET',
|
||||
message: 'Relation target object not found',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, fieldMetadata: 2, totalErrors: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events).toHaveLength(6);
|
||||
|
||||
expect(events?.[0].message).toBe('Sync failed with 3 errors');
|
||||
|
||||
expect(events?.[1].message).toBe('objectMetadata: 1 error');
|
||||
expect(events?.[2].message).toContain('DUPLICATE_NAME');
|
||||
expect(events?.[2].message).toContain('value: postCard');
|
||||
expect(events?.[2].message).toContain('universalIdentifier: obj-uuid-1');
|
||||
|
||||
expect(events?.[3].message).toBe('fieldMetadata: 2 errors');
|
||||
expect(events?.[4].message).toContain('INVALID_TYPE');
|
||||
expect(events?.[4].message).toContain('value: UNKNOWN_TYPE');
|
||||
expect(events?.[4].message).toContain('universalIdentifier: field-uuid-1');
|
||||
expect(events?.[5].message).toContain('MISSING_RELATION_TARGET');
|
||||
expect(events?.[5].message).toContain('universalIdentifier: field-uuid-2');
|
||||
expect(events?.[5].message).not.toContain('value:');
|
||||
});
|
||||
|
||||
it('should include value in details when present', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_VALUE',
|
||||
message: 'Bad value',
|
||||
value: 'some-bad-value',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events?.[2].message).toContain('value: some-bad-value');
|
||||
});
|
||||
|
||||
it('should omit details suffix when no value or universalIdentifier', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR', message: 'Something failed' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { fieldMetadata: 1, totalErrors: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events?.[2].message).toBe(' 1. ERR: Something failed');
|
||||
});
|
||||
|
||||
it('should fall back to entries.length when summary count is missing for a metadata type', () => {
|
||||
const events = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
fieldMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error one' },
|
||||
{ code: 'ERR_2', message: 'Error two' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { totalErrors: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).not.toBeNull();
|
||||
expect(events?.[1].message).toBe('fieldMetadata: 1 error');
|
||||
});
|
||||
|
||||
it('should pluralize correctly for singular and plural counts', () => {
|
||||
const singleError = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [{ code: 'ERR', message: 'Error' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 1, totalErrors: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(singleError?.[0].message).toBe('Sync failed with 1 error');
|
||||
expect(singleError?.[1].message).toBe('objectMetadata: 1 error');
|
||||
|
||||
const multipleErrors = formatSyncErrorEvents({
|
||||
extensions: {
|
||||
errors: {
|
||||
objectMetadata: [
|
||||
{
|
||||
errors: [
|
||||
{ code: 'ERR_1', message: 'Error 1' },
|
||||
{ code: 'ERR_2', message: 'Error 2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: { objectMetadata: 5, totalErrors: 5 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(multipleErrors?.[0].message).toBe('Sync failed with 5 errors');
|
||||
expect(multipleErrors?.[1].message).toBe('objectMetadata: 5 errors');
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
||||
type SyncValidationEntry = {
|
||||
flatEntityMinimalInformation?: { universalIdentifier?: string };
|
||||
errors: { code: string; message: string; value?: string }[];
|
||||
};
|
||||
|
||||
type StructuredSyncError = {
|
||||
message?: string;
|
||||
extensions?: {
|
||||
code?: string;
|
||||
errors?: Record<string, SyncValidationEntry[]>;
|
||||
summary?: Record<string, number> & { totalErrors: number };
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const formatSyncErrorEvents = (
|
||||
error: unknown,
|
||||
): OrchestratorStateStepEvent[] | null => {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const syncError = error as StructuredSyncError;
|
||||
const extensions = syncError.extensions;
|
||||
|
||||
if (!extensions?.errors || !extensions?.summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [];
|
||||
const totalErrors = extensions.summary.totalErrors;
|
||||
|
||||
events.push({
|
||||
message: `Sync failed with ${totalErrors} error${totalErrors !== 1 ? 's' : ''}`,
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
for (const [metadataName, entries] of Object.entries(extensions.errors)) {
|
||||
const count = extensions.summary[metadataName] ?? entries.length;
|
||||
|
||||
events.push({
|
||||
message: `${metadataName}: ${count} error${count !== 1 ? 's' : ''}`,
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
let errorIndex = 1;
|
||||
|
||||
for (const entry of entries) {
|
||||
const universalIdentifier =
|
||||
entry.flatEntityMinimalInformation?.universalIdentifier;
|
||||
|
||||
for (const entryError of entry.errors) {
|
||||
const details: string[] = [];
|
||||
|
||||
if (entryError.value) {
|
||||
details.push(`value: ${entryError.value}`);
|
||||
}
|
||||
|
||||
if (universalIdentifier) {
|
||||
details.push(`universalIdentifier: ${universalIdentifier}`);
|
||||
}
|
||||
|
||||
const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
|
||||
|
||||
events.push({
|
||||
message: ` ${errorIndex}. ${entryError.code}: ${entryError.message}${suffix}`,
|
||||
status: 'error',
|
||||
});
|
||||
errorIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
+10
-4
@@ -32,6 +32,7 @@ export class StartWatchersOrchestratorStep {
|
||||
private notify: () => void;
|
||||
private onFileBuilt: (event: FileBuiltEvent) => void;
|
||||
private shouldSkipTypecheck: () => boolean;
|
||||
private verbose: boolean;
|
||||
|
||||
private manifestWatcher: ManifestWatcher | null = null;
|
||||
private logicFunctionsWatcher: EsbuildWatcher | null = null;
|
||||
@@ -46,12 +47,14 @@ export class StartWatchersOrchestratorStep {
|
||||
notify: () => void;
|
||||
onFileBuilt: (event: FileBuiltEvent) => void;
|
||||
shouldSkipTypecheck: () => boolean;
|
||||
verbose?: boolean;
|
||||
}) {
|
||||
this.state = options.state;
|
||||
this.scheduleSync = options.scheduleSync;
|
||||
this.notify = options.notify;
|
||||
this.onFileBuilt = options.onFileBuilt;
|
||||
this.shouldSkipTypecheck = options.shouldSkipTypecheck;
|
||||
this.verbose = options.verbose ?? false;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -61,6 +64,7 @@ export class StartWatchersOrchestratorStep {
|
||||
this.manifestWatcher = new ManifestWatcher({
|
||||
appPath: this.state.appPath,
|
||||
handleChangeDetected: this.handleChangeDetected.bind(this),
|
||||
verbose: this.verbose,
|
||||
});
|
||||
|
||||
await this.manifestWatcher.start();
|
||||
@@ -133,10 +137,12 @@ export class StartWatchersOrchestratorStep {
|
||||
}
|
||||
|
||||
private handleFileBuilt(event: FileBuiltEvent): void {
|
||||
this.state.addEvent({
|
||||
message: `Successfully built ${event.builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
if (this.verbose) {
|
||||
this.state.addEvent({
|
||||
message: `Successfully built ${event.builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
this.state.steps.uploadFiles.output.builtFileInfos.set(event.builtPath, {
|
||||
checksum: event.checksum,
|
||||
|
||||
+25
-4
@@ -7,6 +7,7 @@ import {
|
||||
type OrchestratorStateStepEvent,
|
||||
type OrchestratorStateSyncStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
@@ -19,19 +20,23 @@ export class SyncApplicationOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
private verbose: boolean;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
state,
|
||||
notify,
|
||||
verbose,
|
||||
}: {
|
||||
apiService: ApiService;
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
verbose?: boolean;
|
||||
}) {
|
||||
this.apiService = apiService;
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
this.verbose = verbose ?? false;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
@@ -74,12 +79,28 @@ export class SyncApplicationOrchestratorStep {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = `Sync failed with error: ${serializeError(syncResult.error)}`;
|
||||
const errorEvents = this.verbose
|
||||
? null
|
||||
: formatSyncErrorEvents(syncResult.error);
|
||||
|
||||
events.push({ message: errorMessage, status: 'error' });
|
||||
step.output = { syncStatus: 'error', error: errorMessage };
|
||||
if (errorEvents) {
|
||||
events.push(...errorEvents);
|
||||
events.push({
|
||||
message: 'Add --verbose to see full error log',
|
||||
status: 'info',
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
message: `Sync failed with error: ${serializeError(syncResult.error)}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed';
|
||||
|
||||
step.output = { syncStatus: 'error', error: summaryMessage };
|
||||
step.status = 'error';
|
||||
this.state.updatePipeline({ status: 'error', error: errorMessage });
|
||||
this.state.updatePipeline({ status: 'error', error: summaryMessage });
|
||||
this.state.updateAllEntitiesStatus('error');
|
||||
this.state.applyStepEvents(events);
|
||||
}
|
||||
|
||||
+53
-8
@@ -14,16 +14,23 @@ export type UploadFilesOrchestratorStepOutput = {
|
||||
export class UploadFilesOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
private verbose: boolean;
|
||||
private uploadedCount = 0;
|
||||
private failedCount = 0;
|
||||
private totalQueued = 0;
|
||||
|
||||
constructor({
|
||||
state,
|
||||
notify,
|
||||
verbose,
|
||||
}: {
|
||||
state: OrchestratorState;
|
||||
notify: () => void;
|
||||
verbose?: boolean;
|
||||
}) {
|
||||
this.state = state;
|
||||
this.notify = notify;
|
||||
this.verbose = verbose ?? false;
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
@@ -58,11 +65,14 @@ export class UploadFilesOrchestratorStep {
|
||||
}
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.totalQueued++;
|
||||
|
||||
this.state.addEvent({
|
||||
message: `Uploading ${builtPath}`,
|
||||
status: 'info',
|
||||
});
|
||||
if (this.verbose) {
|
||||
this.state.addEvent({
|
||||
message: `Uploading ${builtPath}`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
this.state.updateEntityStatus(sourcePath, 'uploading');
|
||||
this.notify();
|
||||
|
||||
@@ -70,12 +80,17 @@ export class UploadFilesOrchestratorStep {
|
||||
.uploadFile({ builtPath, fileFolder })
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
this.state.addEvent({
|
||||
message: `Successfully uploaded ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
this.uploadedCount++;
|
||||
|
||||
if (this.verbose) {
|
||||
this.state.addEvent({
|
||||
message: `Successfully uploaded ${builtPath}`,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
this.state.updateEntityStatus(sourcePath, 'success');
|
||||
} else {
|
||||
this.failedCount++;
|
||||
this.state.addEvent({
|
||||
message: `Failed to upload ${builtPath}: ${result.error}`,
|
||||
status: 'error',
|
||||
@@ -83,6 +98,7 @@ export class UploadFilesOrchestratorStep {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.failedCount++;
|
||||
this.state.addEvent({
|
||||
message: `Upload failed for ${builtPath}: ${error}`,
|
||||
status: 'error',
|
||||
@@ -92,6 +108,7 @@ export class UploadFilesOrchestratorStep {
|
||||
step.output.activeUploads.delete(uploadPromise);
|
||||
|
||||
if (step.output.activeUploads.size === 0) {
|
||||
this.logUploadSummary();
|
||||
step.status = 'done';
|
||||
this.notify();
|
||||
}
|
||||
@@ -111,6 +128,34 @@ export class UploadFilesOrchestratorStep {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private logUploadSummary(): void {
|
||||
if (this.totalQueued === 0) {
|
||||
this.resetCounters();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.failedCount > 0) {
|
||||
this.state.addEvent({
|
||||
message: `Uploaded ${this.uploadedCount}/${this.totalQueued} files (${this.failedCount} failed)`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
this.state.addEvent({
|
||||
message: `Successfully uploaded ${this.uploadedCount} file${this.uploadedCount !== 1 ? 's' : ''}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
this.resetCounters();
|
||||
}
|
||||
|
||||
private resetCounters(): void {
|
||||
this.uploadedCount = 0;
|
||||
this.failedCount = 0;
|
||||
this.totalQueued = 0;
|
||||
}
|
||||
|
||||
private uploadPendingFiles(): void {
|
||||
for (const [
|
||||
builtPath,
|
||||
|
||||
@@ -33,7 +33,6 @@ export const DevUiSyncStatusIndicator = ({
|
||||
return (
|
||||
<Text color={config.color}>
|
||||
{icon} {label}
|
||||
{state.pipeline.error && `: ${state.pipeline.error}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { type OrchestratorStateEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import {
|
||||
type OrchestratorStateEvent,
|
||||
type OrchestratorStateSyncStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { DevUiApplicationPanel } from '@/cli/utilities/dev/ui/components/dev-ui-application-panel';
|
||||
import { DevUiEntityLegend } from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
|
||||
import { DevUiEventItem } from '@/cli/utilities/dev/ui/components/dev-ui-event-log';
|
||||
import { InkProvider, useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
|
||||
import { type DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
|
||||
import React, { useReducer, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
|
||||
|
||||
const ACTIVE_PIPELINE_STATUSES = new Set(['building', 'syncing']);
|
||||
const ACTIVE_PIPELINE_STATUSES = new Set<OrchestratorStateSyncStatus>([
|
||||
'building',
|
||||
'syncing',
|
||||
]);
|
||||
const ANIMATION_TICK_MS = 120;
|
||||
const SETTLE_DELAY_MS = 80;
|
||||
|
||||
const DevUI = ({
|
||||
uiStateManager,
|
||||
@@ -18,22 +25,56 @@ const DevUI = ({
|
||||
|
||||
const [, forceRender] = useReducer((tick: number) => tick + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
return uiStateManager.subscribe(() => forceRender());
|
||||
}, [uiStateManager]);
|
||||
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastStateRenderRef = useRef(0);
|
||||
|
||||
const state = uiStateManager.getSnapshot();
|
||||
const isActive = ACTIVE_PIPELINE_STATUSES.has(state.pipeline.status);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
const scheduleSettledRender = useCallback(() => {
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
}
|
||||
|
||||
const timer = setInterval(() => forceRender(), ANIMATION_TICK_MS);
|
||||
settleTimerRef.current = setTimeout(() => {
|
||||
settleTimerRef.current = null;
|
||||
lastStateRenderRef.current = Date.now();
|
||||
forceRender();
|
||||
}, SETTLE_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return uiStateManager.subscribe(() => {
|
||||
scheduleSettledRender();
|
||||
});
|
||||
}, [uiStateManager, scheduleSettledRender]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const snapshot = uiStateManager.getSnapshot();
|
||||
|
||||
if (!ACTIVE_PIPELINE_STATUSES.has(snapshot.pipeline.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if a state-change render happened recently to avoid
|
||||
// double-rendering while Static items are being added.
|
||||
if (Date.now() - lastStateRenderRef.current < ANIMATION_TICK_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
forceRender();
|
||||
}, ANIMATION_TICK_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isActive]);
|
||||
}, [uiStateManager]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const state = uiStateManager.getSnapshot();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user