Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f57b2b7
initial steps of the plugins porlet migration
hmoreras Feb 19, 2026
f78f74e
add fixed pagination to the bottom
hmoreras Feb 19, 2026
0073df3
ui fixes and elements position
hmoreras Mar 12, 2026
e149a30
initial progess drag and drop
hmoreras Mar 12, 2026
ce50cf1
notifications changes
hmoreras Mar 13, 2026
f4d12ab
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 16, 2026
4fc540f
add t bundle progress
hmoreras Mar 17, 2026
ff80011
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 18, 2026
0759f6c
progess in the UI and design feedback
hmoreras Mar 20, 2026
a7d1a23
rename dynamic-plugins to plugins-legacy and register Angular plugins…
hmoreras Mar 20, 2026
d6737a1
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 20, 2026
27116fc
prevent fake empty state during OSGi restart/actions with forkJoin, r…
hmoreras Mar 20, 2026
561d90e
OSGi types/tests, extra-packages DOM specs, drag-drop i18n, store doc…
hmoreras Mar 20, 2026
993e886
fix tsconfig violations, loadBundles status leak, RESET magic string,…
hmoreras Mar 20, 2026
88fc367
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 20, 2026
d9a4ef3
remove plugins-legacy from menu on upgrade and align exception handli…
hmoreras Mar 20, 2026
8cf8cc5
catch Exception instead of DotDataException in forceRun to fix compil…
hmoreras Mar 21, 2026
25fe377
fix: update portlet count to 50 and add plugins assertion in Serializ…
hmoreras Mar 21, 2026
1052385
fix: add dynamic-plugins compatibility alias in portlet.xml to fix fr…
hmoreras Mar 23, 2026
d47d3a0
fix: add dynamic-plugins Angular route so fresh-install menu navigati…
hmoreras Mar 23, 2026
1bf88d2
refactor: remove dynamic-plugins portlet — align plugins entries to t…
hmoreras Mar 23, 2026
ba9368a
put the order correctly
hmoreras Mar 23, 2026
c399344
refactor: improve dot-plugins portlet quality and loading UX
hmoreras Mar 24, 2026
4249021
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 24, 2026
1224d38
Merge branch 'issue-34732-plugins-portlet' of https://github.com/dotC…
hmoreras Mar 24, 2026
fcc38bd
Added confirmation dialog before processing exports, matching the leg…
hmoreras Mar 24, 2026
b4517f8
add test to MainSuite + couple of labels
hmoreras Mar 25, 2026
901a1c2
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 25, 2026
b969d67
feat: improve dot-plugins portlet UX — uploading wall, search, show-u…
hmoreras Mar 26, 2026
5cf2839
Merge branch 'issue-34732-plugins-portlet' of https://github.com/dotC…
hmoreras Mar 26, 2026
43deaf4
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 26, 2026
19504e8
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 27, 2026
7cc340e
fix(portlets): make Task260320 rollback-safe; remove dead Task260321
hmoreras Mar 27, 2026
8e33ebf
Merge branch 'main' into issue-34732-plugins-portlet
hmoreras Mar 30, 2026
3e2f2a5
upload error subscruption + spinnner in the uploading wall
hmoreras Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,19 @@ const PORTLETS_ANGULAR: Route[] = [
},
{
path: 'tags',
canActivate: [MenuGuardService],
canActivateChild: [MenuGuardService],
data: { reuseRoute: false },
loadChildren: () => import('@dotcms/portlets/dot-tags/portlet').then((m) => m.dotTagsRoutes)
},
{
path: 'plugins',
canActivate: [MenuGuardService],
canActivateChild: [MenuGuardService],
data: { reuseRoute: false },
loadChildren: () =>
import('@dotcms/portlets/dot-plugins/portlet').then((m) => m.dotPluginsRoutes)
},
{
path: '',
canActivate: [MenuGuardService],
Expand Down
2 changes: 2 additions & 0 deletions core-web/libs/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,5 @@ export * from './lib/dot-page-contenttype/dot-page-contenttype.service';
export * from './lib/dot-favorite-contenttype/dot-favorite-contenttype.service';
export * from './lib/dot-content-drive/dot-content-drive.service';
export * from './lib/dot-usage/dot-usage.service';
export * from './lib/dot-osgi/bundle-map.model';
export * from './lib/dot-osgi/dot-osgi.service';
50 changes: 50 additions & 0 deletions core-web/libs/data-access/src/lib/dot-osgi/bundle-map.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* OSGi bundle representation matching backend BundleMap.
* @see dotCMS OSGIResource ResponseEntityBundleListView
*/

/**
* Sentinel value accepted by the backend's extra-packages PUT endpoint to reset
* the exported-packages list to its installation defaults.
* @see OSGIResource#updateExtraPackages
*/
export const OSGI_EXTRA_PACKAGES_RESET = 'RESET';

/** OSGi bundle states (org.osgi.framework.Bundle constants). */
export const BUNDLE_STATE = {
UNINSTALLED: 1,
INSTALLED: 2,
RESOLVED: 4,
STARTING: 8,
STOPPING: 16,
ACTIVE: 32
} as const;

/** Union of valid OSGi bundle state integers (values of {@link BUNDLE_STATE}). */
export type OsgiBundleState = (typeof BUNDLE_STATE)[keyof typeof BUNDLE_STATE];

export interface BundleMap {
bundleId: number;
symbolicName: string;
location: string;
jarFile: string;
state: OsgiBundleState;
version: string;
separator: string;
isSystem: boolean;
javaVersion?: string;
javaClassVersion?: number;
isMultiRelease?: boolean;
isBuiltWithMaven?: boolean;
usesDotcmsApis?: boolean;
dotcmsCoreDependencyVersion?: string;
}

/** Unified row model for the plugins table (installed bundles + undeployed jars). */
export interface PluginRow {
jarFile: string;
symbolicName: string;
state: OsgiBundleState | 'undeployed';
bundleId?: number;
version?: string;
}
179 changes: 179 additions & 0 deletions core-web/libs/data-access/src/lib/dot-osgi/dot-osgi.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator/jest';

import { BUNDLE_STATE, BundleMap } from './bundle-map.model';
import { DotOsgiService } from './dot-osgi.service';

function mockBundle(overrides: Partial<BundleMap> = {}): BundleMap {
return {
bundleId: 1,
symbolicName: 'test',
location: 'file:plugins/test.jar',
jarFile: 'test.jar',
state: BUNDLE_STATE.ACTIVE,
version: '1.0.0',
separator: '|',
isSystem: false,
...overrides
};
}

describe('DotOsgiService', () => {
let spectator: SpectatorHttp<DotOsgiService>;

const createHttp = createHttpFactory({
service: DotOsgiService
});

beforeEach(() => {
spectator = createHttp();
});

describe('getInstalledBundles', () => {
it('should GET with ignoresystembundles=false by default', () => {
const entity = [mockBundle()];
spectator.service.getInstalledBundles().subscribe((res) => {
expect(res.entity).toEqual(entity);
});
const req = spectator.expectOne(
'/api/v1/osgi?ignoresystembundles=false',
HttpMethod.GET
);
req.flush({ entity });
});

it('should GET with ignoresystembundles=false when passed explicitly', () => {
spectator.service.getInstalledBundles(false).subscribe();
spectator.expectOne('/api/v1/osgi?ignoresystembundles=false', HttpMethod.GET).flush({});
});

it('should GET with ignoresystembundles=true when requested', () => {
spectator.service.getInstalledBundles(true).subscribe();
spectator.expectOne('/api/v1/osgi?ignoresystembundles=true', HttpMethod.GET).flush({});
});
});

describe('getAvailablePlugins', () => {
it('should GET available-plugins', () => {
const entity = ['plugin1.jar', 'plugin2.jar'];
spectator.service.getAvailablePlugins().subscribe((res) => {
expect(res.entity).toEqual(entity);
});
const req = spectator.expectOne('/api/v1/osgi/available-plugins', HttpMethod.GET);
req.flush({ entity });
});
});

describe('uploadBundles', () => {
it('should POST FormData with each file under key "file"', () => {
const fileA = new File(['a'], 'a.jar', { type: 'application/java-archive' });
const fileB = new File(['b'], 'b.jar', { type: 'application/java-archive' });

spectator.service.uploadBundles([fileA, fileB]).subscribe();

const req = spectator.expectOne('/api/v1/osgi', HttpMethod.POST);
expect(req.request.body).toBeInstanceOf(FormData);
const body = req.request.body as FormData;
expect(body.getAll('file')).toEqual([fileA, fileB]);
req.flush({});
});
});

describe('deploy', () => {
it('should PUT deploy URL with plain jar name', () => {
spectator.service.deploy('my.jar').subscribe();
const req = spectator.expectOne('/api/v1/osgi/jar/my.jar/_deploy', HttpMethod.PUT);
expect(req.request.body).toEqual({});
req.flush({});
});

it('should encode jar name in deploy path', () => {
spectator.service.deploy('my plugin.jar').subscribe();
spectator
.expectOne('/api/v1/osgi/jar/my%20plugin.jar/_deploy', HttpMethod.PUT)
.flush({});
});
});

describe('start', () => {
it('should PUT start URL', () => {
spectator.service.start('my.jar').subscribe();
const req = spectator.expectOne('/api/v1/osgi/jar/my.jar/_start', HttpMethod.PUT);
expect(req.request.body).toEqual({});
req.flush({});
});

it('should encode jar name in start path', () => {
spectator.service.start('a/b.jar').subscribe();
spectator.expectOne('/api/v1/osgi/jar/a%2Fb.jar/_start', HttpMethod.PUT).flush({});
});
});

describe('stop', () => {
it('should PUT stop URL', () => {
spectator.service.stop('my.jar').subscribe();
const req = spectator.expectOne('/api/v1/osgi/jar/my.jar/_stop', HttpMethod.PUT);
expect(req.request.body).toEqual({});
req.flush({});
});
});

describe('undeploy', () => {
it('should DELETE jar URL', () => {
spectator.service.undeploy('my.jar').subscribe();
spectator.expectOne('/api/v1/osgi/jar/my.jar', HttpMethod.DELETE).flush({});
});

it('should encode jar name in undeploy path', () => {
spectator.service.undeploy('x+y.jar').subscribe();
spectator.expectOne('/api/v1/osgi/jar/x%2By.jar', HttpMethod.DELETE).flush({});
});
});

describe('processExports', () => {
it('should GET processExports with encoded bundle symbolic name', () => {
spectator.service.processExports('com.example.plugin').subscribe();
spectator
.expectOne('/api/v1/osgi/_processExports/com.example.plugin', HttpMethod.GET)
.flush({});
});

it('should encode special characters in bundle name', () => {
spectator.service.processExports('bundle name').subscribe();
spectator
.expectOne('/api/v1/osgi/_processExports/bundle%20name', HttpMethod.GET)
.flush({});
});
});

describe('getExtraPackages', () => {
it('should GET extra-packages', () => {
const entity = 'pkg1;version=1\npkg2;version=2';
spectator.service.getExtraPackages().subscribe((res) => {
expect(res.entity).toEqual(entity);
});
const req = spectator.expectOne('/api/v1/osgi/extra-packages', HttpMethod.GET);
req.flush({ entity });
});
});

describe('updateExtraPackages', () => {
it('should PUT extra-packages with body', () => {
const packages = 'com.foo;version=1';
spectator.service.updateExtraPackages(packages).subscribe((res) => {
expect(res.entity).toEqual(packages);
});
const req = spectator.expectOne('/api/v1/osgi/extra-packages', HttpMethod.PUT);
expect(req.request.body).toEqual({ packages });
req.flush({ entity: packages });
});
});

describe('restart', () => {
it('should PUT _restart with empty body', () => {
spectator.service.restart().subscribe();
const req = spectator.expectOne('/api/v1/osgi/_restart', HttpMethod.PUT);
expect(req.request.body).toEqual({});
req.flush({});
});
});
});
83 changes: 83 additions & 0 deletions core-web/libs/data-access/src/lib/dot-osgi/dot-osgi.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Observable } from 'rxjs';

import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';

import { DotCMSAPIResponse } from '@dotcms/dotcms-models';

import { BundleMap } from './bundle-map.model';

const OSGI_BASE = '/api/v1/osgi';

@Injectable({
providedIn: 'root'
})
export class DotOsgiService {
readonly #http = inject(HttpClient);

getInstalledBundles(ignoreSystemBundles = false): Observable<DotCMSAPIResponse<BundleMap[]>> {
const params = new HttpParams().set('ignoresystembundles', String(ignoreSystemBundles));
return this.#http.get<DotCMSAPIResponse<BundleMap[]>>(OSGI_BASE, { params });
}

getAvailablePlugins(): Observable<DotCMSAPIResponse<string[]>> {
return this.#http.get<DotCMSAPIResponse<string[]>>(`${OSGI_BASE}/available-plugins`);
}

uploadBundles(files: File[]): Observable<DotCMSAPIResponse<unknown>> {
const formData = new FormData();
files.forEach((file) => formData.append('file', file, file.name));
return this.#http.post<DotCMSAPIResponse<unknown>>(OSGI_BASE, formData);
}

deploy(jar: string): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.put<DotCMSAPIResponse<unknown>>(
`${OSGI_BASE}/jar/${encodeURIComponent(jar)}/_deploy`,
{}
);
}

start(jar: string): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.put<DotCMSAPIResponse<unknown>>(
`${OSGI_BASE}/jar/${encodeURIComponent(jar)}/_start`,
{}
);
}

stop(jar: string): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.put<DotCMSAPIResponse<unknown>>(
`${OSGI_BASE}/jar/${encodeURIComponent(jar)}/_stop`,
{}
);
}

undeploy(jar: string): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.delete<DotCMSAPIResponse<unknown>>(
`${OSGI_BASE}/jar/${encodeURIComponent(jar)}`
);
}

processExports(bundle: string): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.get<DotCMSAPIResponse<unknown>>(
`${OSGI_BASE}/_processExports/${encodeURIComponent(bundle)}`
);
}

getExtraPackages(): Observable<DotCMSAPIResponse<string>> {
return this.#http.get<DotCMSAPIResponse<string>>(`${OSGI_BASE}/extra-packages`);
}

updateExtraPackages(packages: string): Observable<DotCMSAPIResponse<string>> {
return this.#http.put<DotCMSAPIResponse<string>>(`${OSGI_BASE}/extra-packages`, {
packages
});
}

/**
* Restarts the OSGI framework and all dynamic plugins (cluster-wide).
* Returns the server message (e.g. "OSGI Framework Restarted") on success.
*/
restart(): Observable<DotCMSAPIResponse<unknown>> {
return this.#http.put<DotCMSAPIResponse<unknown>>(`${OSGI_BASE}/_restart`, {});
}
}
18 changes: 18 additions & 0 deletions core-web/libs/portlets/dot-plugins/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.base.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
11 changes: 11 additions & 0 deletions core-web/libs/portlets/dot-plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# dot-plugins portlet

Plugins admin UI (OSGi bundles list, upload, exported packages).

## Tests

```bash
yarn nx run portlets-dot-plugins-portlet:test
```

Entry: `@dotcms/portlets/dot-plugins/portlet` → `dotPluginsRoutes` (`DotPluginsShellComponent`).
22 changes: 22 additions & 0 deletions core-web/libs/portlets/dot-plugins/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export default {
displayName: 'portlets-dot-plugins-portlet',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/portlets/dot-plugins',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
isolatedModules: true,
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}
]
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment'
]
};
Loading
Loading