From 4ae55609c6c12726ab4e9bc236c59b9c9cbf2e6a Mon Sep 17 00:00:00 2001 From: Robin Shen Date: Fri, 14 Apr 2023 10:53:25 +0800 Subject: [PATCH] EE version --- pom.xml | 1 + server-ee/pom.xml | 25 + server-ee/server-ee-clustering/pom.xml | 13 + .../ee/clustering/ClusteringModule.java | 19 + .../ee/clustering/EEClusterManager.java | 251 +++++++ .../ee/clustering/EEClusterManagerTest.java | 228 +++++++ server-ee/server-ee-dashboard/pom.xml | 13 + .../server/ee/dashboard/DashboardModule.java | 59 ++ .../server/ee/dashboard/DashboardPage.html | 56 ++ .../server/ee/dashboard/DashboardPage.java | 629 ++++++++++++++++++ .../dashboard/DashboardResourceReference.java | 32 + .../ee/dashboard/DashboardShareBean.java | 126 ++++ .../ee/dashboard/EEMainMenuCustomization.java | 32 + .../ee/dashboard/WidgetEditCallback.java | 17 + .../server/ee/dashboard/WidgetPanel.html | 9 + .../server/ee/dashboard/WidgetPanel.java | 253 +++++++ .../onedev/server/ee/dashboard/dashboard.css | 128 ++++ .../onedev/server/ee/dashboard/dashboard.js | 337 ++++++++++ .../ee/dashboard/widgets/BuildListWidget.java | 125 ++++ .../ee/dashboard/widgets/IssueListWidget.java | 109 +++ .../dashboard/widgets/MarkdownBlobWidget.java | 331 +++++++++ .../ee/dashboard/widgets/MarkdownWidget.java | 45 ++ .../widgets/MilestoneListWidget.java | 86 +++ .../dashboard/widgets/ProjectListWidget.java | 52 ++ .../widgets/PullRequestListWidget.java | 107 +++ .../ee/dashboard/widgets/WidgetGroup.java | 7 + .../ProjectOverviewCssResourceReference.java | 13 + .../projectoverview/ProjectOverviewPanel.html | 9 + .../projectoverview/ProjectOverviewPanel.java | 110 +++ .../ProjectOverviewWidget.java | 86 +++ .../projectoverview/project-overview.css | 6 + server-ee/server-ee-storage/pom.xml | 13 + .../server/ee/storage/EEStorageManager.java | 145 ++++ .../server/ee/storage/StorageModule.java | 25 + .../server/ee/storage/StorageSetting.java | 33 + server-ee/server-ee-terminal/pom.xml | 13 + .../server/ee/terminal/BuildTerminalPage.html | 3 + .../server/ee/terminal/BuildTerminalPage.java | 146 ++++ .../BuildTerminalResourceReference.java | 26 + .../server/ee/terminal/EETerminalManager.java | 259 ++++++++ .../server/ee/terminal/TerminalModule.java | 32 + .../server/ee/terminal/TerminalSession.java | 24 + .../server/ee/terminal/build-terminal.js | 37 ++ server-product/pom.xml | 20 + 44 files changed, 4090 insertions(+) create mode 100644 server-ee/pom.xml create mode 100644 server-ee/server-ee-clustering/pom.xml create mode 100644 server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/ClusteringModule.java create mode 100644 server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/EEClusterManager.java create mode 100644 server-ee/server-ee-clustering/src/test/java/io/onedev/server/ee/clustering/EEClusterManagerTest.java create mode 100644 server-ee/server-ee-dashboard/pom.xml create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardModule.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.html create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardResourceReference.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardShareBean.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/EEMainMenuCustomization.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetEditCallback.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.html create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.css create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.js create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/BuildListWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/IssueListWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownBlobWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MilestoneListWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/ProjectListWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/PullRequestListWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/WidgetGroup.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewCssResourceReference.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.html create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewWidget.java create mode 100644 server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/project-overview.css create mode 100644 server-ee/server-ee-storage/pom.xml create mode 100644 server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/EEStorageManager.java create mode 100644 server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageModule.java create mode 100644 server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageSetting.java create mode 100644 server-ee/server-ee-terminal/pom.xml create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.html create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.java create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalResourceReference.java create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/EETerminalManager.java create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalModule.java create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalSession.java create mode 100644 server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/build-terminal.js diff --git a/pom.xml b/pom.xml index d938cc51d4..20760ba708 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,7 @@ server-core server-plugin + server-ee server-product diff --git a/server-ee/pom.xml b/server-ee/pom.xml new file mode 100644 index 0000000000..f7b65cc392 --- /dev/null +++ b/server-ee/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + server-ee + pom + + io.onedev + server + 8.1.0 + + + + io.onedev + server-core + ${project.version} + + + + server-ee-dashboard + server-ee-terminal + server-ee-storage + server-ee-clustering + + diff --git a/server-ee/server-ee-clustering/pom.xml b/server-ee/server-ee-clustering/pom.xml new file mode 100644 index 0000000000..6a5d057c2b --- /dev/null +++ b/server-ee/server-ee-clustering/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + server-ee-clustering + + io.onedev + server-ee + 8.1.0 + + + io.onedev.server.ee.clustering.ClusteringModule + + diff --git a/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/ClusteringModule.java b/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/ClusteringModule.java new file mode 100644 index 0000000000..4a7004f855 --- /dev/null +++ b/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/ClusteringModule.java @@ -0,0 +1,19 @@ +package io.onedev.server.ee.clustering; + +import io.onedev.commons.loader.AbstractPluginModule; +import io.onedev.server.cluster.ClusterManager; + +/** + * NOTE: Do not forget to rename moduleClass property defined in the pom if you've renamed this class. + * + */ +public class ClusteringModule extends AbstractPluginModule { + + @Override + protected void configure() { + super.configure(); + + bind(ClusterManager.class).to(EEClusterManager.class); + } + +} diff --git a/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/EEClusterManager.java b/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/EEClusterManager.java new file mode 100644 index 0000000000..befa452623 --- /dev/null +++ b/server-ee/server-ee-clustering/src/main/java/io/onedev/server/ee/clustering/EEClusterManager.java @@ -0,0 +1,251 @@ +package io.onedev.server.ee.clustering; + +import io.onedev.server.ServerConfig; +import io.onedev.server.cluster.DefaultClusterManager; +import io.onedev.server.entitymanager.SettingManager; +import io.onedev.server.event.ListenerRegistry; +import io.onedev.server.persistence.DataManager; +import io.onedev.server.persistence.HibernateConfig; +import io.onedev.server.replica.ProjectReplica; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.*; + +import static io.onedev.server.replica.ProjectReplica.Type.*; +import static java.util.Collections.reverse; +import static java.util.Comparator.comparing; +import static java.util.Comparator.comparingInt; +import static java.util.stream.Collectors.toList; + +@Singleton +public class EEClusterManager extends DefaultClusterManager { + + private final SettingManager settingManager; + + @Inject + public EEClusterManager(ServerConfig serverConfig, DataManager dataManager, + SettingManager settingManager, ListenerRegistry listenerRegistry, + HibernateConfig hibernateConfig) { + super(serverConfig, dataManager, listenerRegistry, hibernateConfig); + this.settingManager = settingManager; + } + + @Override + public void redistributeProjects(Map> replicas) { + var servers = new LinkedHashSet<>(getServerAddresses()); + var replicaCount = settingManager.getClusterSetting().getReplicaCount(); + // Normalize distributions + for (var entry: replicas.values()) { + var replicasOfProject = entry.entrySet().stream() + .filter(it -> servers.contains(it.getKey())) + .map(Map.Entry::getValue) + .sorted(comparing(ProjectReplica::getVersion)) + .collect(toList()); + reverse(replicasOfProject); + var primaryFound = false; + for (var replica : replicasOfProject) { + if (replica.getType() == PRIMARY) { + if (primaryFound) + replica.setType(BACKUP); + else + primaryFound = true; + } + } + if (!primaryFound) { + if (!replicasOfProject.isEmpty()) { + replicasOfProject.iterator().next().setType(PRIMARY); + } else { + var replica = new ProjectReplica(); + replica.setType(PRIMARY); + replica.setVersion(0); + replicasOfProject.add(replica); + entry.put(servers.iterator().next(), replica); + } + } + int backupCount = 0; + for (var replica : replicasOfProject) { + if (replica.getType() == BACKUP && ++backupCount > replicaCount - 1) + replica.setType(REDUNDANT); + } + if (backupCount < replicaCount - 1) { + for (var replica : replicasOfProject) { + if (replica.getType() == REDUNDANT) { + replica.setType(BACKUP); + backupCount++; + if (backupCount == replicaCount - 1) + break; + } + } + } + for (int i = 0; i < replicaCount - 1 - backupCount; i++) { + for (var server : servers) { + if (!entry.containsKey(server)) { + ProjectReplica replica = new ProjectReplica(); + replica.setType(BACKUP); + replica.setVersion(0); + replicasOfProject.add(replica); + entry.put(server, replica); + break; + } + } + } + } + + // Now distribute all replicas across all servers evenly + Map serverLoads = getServerLoads(replicas, servers, EnumSet.of(PRIMARY, BACKUP)); + + Map> serverReplicas = new LinkedHashMap<>(); + for (var projectToReplicas: replicas.entrySet()) { + var projectId = projectToReplicas.getKey(); + for (var serverToReplica: projectToReplicas.getValue().entrySet()) { + var server = serverToReplica.getKey(); + if (servers.contains(server)) { + var replica = serverToReplica.getValue(); + if (replica.getType() != REDUNDANT) + serverReplicas.computeIfAbsent(server, k -> new LinkedHashMap<>()).put(projectId, replica); + } + } + } + servers.forEach(it->serverReplicas.putIfAbsent(it, new LinkedHashMap<>())); + + while (true) { + String minLoadServer = null, maxLoadServer = null; + int minServerLoad = replicas.size() + 1, maxServerLoad = -1; + for (var entry : serverLoads.entrySet()) { + if (entry.getValue() > maxServerLoad) { + maxLoadServer = entry.getKey(); + maxServerLoad = entry.getValue(); + } + if (entry.getValue() < minServerLoad) { + minLoadServer = entry.getKey(); + minServerLoad = entry.getValue(); + } + } + if (maxServerLoad - minServerLoad >= 2) { + var replicasOnMaxLoadServer = serverReplicas.get(maxLoadServer); + var replicasOnMinLoadServer = serverReplicas.get(minLoadServer); + var moveLoad = (maxServerLoad - minServerLoad) / 2; + for (var i = 0; i < moveLoad; i++) { + for (var it = replicasOnMaxLoadServer.entrySet().iterator(); it.hasNext();) { + var entry = it.next(); + var projectId = entry.getKey(); + var replica = entry.getValue(); + if (!replicasOnMinLoadServer.containsKey(projectId)) { + var replicasOfProject = replicas.get(projectId); + var prevReplica = replicasOfProject.get(minLoadServer); + if (prevReplica != null) { + prevReplica.setType(replica.getType()); + replicasOnMinLoadServer.put(projectId, prevReplica); + } else { + var newReplica = new ProjectReplica(); + newReplica.setType(replica.getType()); + newReplica.setVersion(0); + replicasOfProject.put(minLoadServer, newReplica); + replicasOnMinLoadServer.put(projectId, newReplica); + } + replica.setType(REDUNDANT); + it.remove(); + break; + } + } + } + serverLoads.put(maxLoadServer, maxServerLoad - moveLoad); + serverLoads.put(minLoadServer, minServerLoad + moveLoad); + } else { + break; + } + } + + // Now distribute primary replicas across servers evenly (best try) + serverLoads = getServerLoads(replicas, servers, EnumSet.of(PRIMARY)); + + for (var replicasOfProject: replicas.values()) { + var primaryServerLoad = -1; + Map.Entry primaryEntry = null; + for (var serverToReplica: replicasOfProject.entrySet()) { + var server = serverToReplica.getKey(); + if (servers.contains(server) && serverToReplica.getValue().getType() == PRIMARY) { + primaryEntry = serverToReplica; + primaryServerLoad = serverLoads.get(server); + break; + } + } + + var minBackupServerLoad = replicas.size(); + Map.Entry minBackupEntry = null; + for (var serverToReplica: replicasOfProject.entrySet()) { + var server = serverToReplica.getKey(); + var replica = serverToReplica.getValue(); + if (servers.contains(server) && replica.getType() == BACKUP) { + var serverLoad = serverLoads.get(server); + if (serverLoad < minBackupServerLoad) { + minBackupServerLoad = serverLoad; + minBackupEntry = serverToReplica; + } + } + } + + if (primaryServerLoad - minBackupServerLoad >= 2) { + primaryEntry.getValue().setType(BACKUP); + minBackupEntry.getValue().setType(PRIMARY); + serverLoads.put(primaryEntry.getKey(), primaryServerLoad - 1); + serverLoads.put(minBackupEntry.getKey(), minBackupServerLoad + 1); + } + } + } + + private Map getServerLoads(Map> replicas, + Collection servers, Set replicaTypes) { + Map serverLoads = new LinkedHashMap<>(); + for (var replicasOfProject: replicas.values()) { + for (var serverToReplica: replicasOfProject.entrySet()) { + var server = serverToReplica.getKey(); + if (servers.contains(server)) { + var replica = serverToReplica.getValue(); + if (replicaTypes.contains(replica.getType())) { + Integer serverLoad = serverLoads.get(server); + if (serverLoad == null) + serverLoad = 0; + serverLoads.put(server, ++serverLoad); + } + } + } + } + servers.forEach(it->serverLoads.putIfAbsent(it, 0)); + return serverLoads; + } + + @Override + public Map addProject(Map> replicas, Long projectId) { + var servers = new HashSet<>(getServerAddresses()); + var replicaCount = settingManager.getClusterSetting().getReplicaCount(); + + var serverLoads = getServerLoads(replicas, servers, EnumSet.of(PRIMARY, BACKUP)); + var sortedServers = new ArrayList<>(servers); + sortedServers.sort(comparingInt(serverLoads::get)); + + Map replicasOfNewProject = new HashMap<>(); + for (var server: sortedServers) { + var replica = new ProjectReplica(); + replica.setType(BACKUP); + replica.setVersion(0); + replicasOfNewProject.put(server, replica); + if (replicasOfNewProject.size() >= replicaCount) + break; + } + + serverLoads = getServerLoads(replicas, servers, EnumSet.of(PRIMARY)); + sortedServers = new ArrayList<>(replicasOfNewProject.keySet()); + var primaryServer = sortedServers.stream().min(comparing(serverLoads::get)).get(); + replicasOfNewProject.get(primaryServer).setType(PRIMARY); + + return replicasOfNewProject; + } + + @Override + public boolean isClusteringSupported() { + return true; + } + +} \ No newline at end of file diff --git a/server-ee/server-ee-clustering/src/test/java/io/onedev/server/ee/clustering/EEClusterManagerTest.java b/server-ee/server-ee-clustering/src/test/java/io/onedev/server/ee/clustering/EEClusterManagerTest.java new file mode 100644 index 0000000000..e84aed0fb3 --- /dev/null +++ b/server-ee/server-ee-clustering/src/test/java/io/onedev/server/ee/clustering/EEClusterManagerTest.java @@ -0,0 +1,228 @@ +package io.onedev.server.ee.clustering; + +import io.onedev.server.ServerConfig; +import io.onedev.server.entitymanager.SettingManager; +import io.onedev.server.event.ListenerRegistry; +import io.onedev.server.model.support.administration.ClusterSetting; +import io.onedev.server.persistence.DataManager; +import io.onedev.server.persistence.HibernateConfig; +import io.onedev.server.replica.ProjectReplica; +import junit.framework.TestCase; + +import java.util.*; + +import static io.onedev.server.replica.ProjectReplica.Type.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class EEClusterManagerTest extends TestCase { + + public void testRedistributeProjects() { + var serverConfig = mock(ServerConfig.class); + var listenerRegistry = mock(ListenerRegistry.class); + var dataManager = mock(DataManager.class); + var hibernateConfig = mock(HibernateConfig.class); + var settingManager = mock(SettingManager.class); + + var clusterSetting = new ClusterSetting(); + when(settingManager.getClusterSetting()).thenReturn(clusterSetting); + + var servers = new ArrayList(); + var clusterManager = new EEClusterManager(serverConfig, dataManager, settingManager, + listenerRegistry, hibernateConfig) { + @Override + public List getServerAddresses() { + return servers; + } + }; + + String server0 = "server0"; + String server1 = "server1"; + String server2 = "server2"; + + servers.add(server1); + servers.add(server2); + + Map> replicas = new LinkedHashMap<>(); + + Map replicasOfProject = new LinkedHashMap<>(); + + var replica = new ProjectReplica(); + replica.setType(BACKUP); + replica.setVersion(1); + replicasOfProject.put("server0", replica); + + replica = new ProjectReplica(); + replica.setType(PRIMARY); + replica.setVersion(1); + replicasOfProject.put("server1", replica); + + replica = new ProjectReplica(); + replica.setType(BACKUP); + replica.setVersion(1); + replicasOfProject.put("server2", replica); + + replicas.put(1L, replicasOfProject); + + replicasOfProject = new LinkedHashMap<>(); + + replica = new ProjectReplica(); + replica.setType(BACKUP); + replica.setVersion(1); + replicasOfProject.put("server1", replica); + + replica = new ProjectReplica(); + replica.setType(PRIMARY); + replica.setVersion(1); + replicasOfProject.put("server2", replica); + + replicas.put(2L, replicasOfProject); + + clusterSetting.setReplicaCount(1); + clusterManager.redistributeProjects(replicas); + assertEquals(BACKUP, replicas.get(1L).get(server0).getType()); + assertEquals(PRIMARY, replicas.get(1L).get(server1).getType()); + assertEquals(REDUNDANT, replicas.get(1L).get(server2).getType()); + assertEquals(REDUNDANT, replicas.get(2L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server2).getType()); + + replicas = new LinkedHashMap<>(); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject = new LinkedHashMap<>(); + replicasOfProject.put(server1, replica); + replicas.put(1L, replicasOfProject); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject = new LinkedHashMap<>(); + replicasOfProject.put(server1, replica); + replicas.put(2L, replicasOfProject); + + clusterManager.redistributeProjects(replicas); + assertEquals(REDUNDANT, replicas.get(1L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(1L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server1).getType()); + + clusterSetting.setReplicaCount(2); + clusterManager.redistributeProjects(replicas); + assertEquals(BACKUP, replicas.get(1L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(1L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server2).getType()); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject = new LinkedHashMap<>(); + replicasOfProject.put(server1, replica); + replicas.put(3L, replicasOfProject); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject = new LinkedHashMap<>(); + replicasOfProject.put(server2, replica); + replicas.put(4L, replicasOfProject); + + String server3 = "server3"; + servers.add(server3); + clusterManager.redistributeProjects(replicas); + assertEquals(PRIMARY, replicas.get(1L).get(server1).getType()); + assertEquals(REDUNDANT, replicas.get(1L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(1L).get(server3).getType()); + assertEquals(REDUNDANT, replicas.get(2L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(3L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(3L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(4L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(4L).get(server2).getType()); + + replicasOfProject = new LinkedHashMap<>(); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(BACKUP); + replicasOfProject.put(server0, replica); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject.put(server1, replica); + + replicas.put(5L, replicasOfProject); + + replica = new ProjectReplica(); + replica.setVersion(1); + replica.setType(PRIMARY); + replicasOfProject = new LinkedHashMap<>(); + replicasOfProject.put(server1, replica); + replicas.put(6L, replicasOfProject); + clusterManager.redistributeProjects(replicas); + assertEquals(PRIMARY, replicas.get(1L).get(server1).getType()); + assertEquals(REDUNDANT, replicas.get(1L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(1L).get(server3).getType()); + assertEquals(REDUNDANT, replicas.get(2L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server3).getType()); + assertEquals(REDUNDANT, replicas.get(3L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(3L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(3L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(4L).get(server1).getType()); + assertEquals(REDUNDANT, replicas.get(4L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(4L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(5L).get(server0).getType()); + assertEquals(BACKUP, replicas.get(5L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(5L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(6L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(6L).get(server2).getType()); + + String server4 = "server4"; + servers.add(server4); + + clusterSetting.setReplicaCount(3); + clusterManager.redistributeProjects(replicas); + assertEquals(BACKUP, replicas.get(1L).get(server1).getType()); + assertEquals(REDUNDANT, replicas.get(1L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(1L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(1L).get(server4).getType()); + assertEquals(REDUNDANT, replicas.get(2L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server4).getType()); + assertEquals(REDUNDANT, replicas.get(3L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(3L).get(server2).getType()); + assertEquals(PRIMARY, replicas.get(3L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(3L).get(server4).getType()); + assertEquals(BACKUP, replicas.get(4L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(4L).get(server2).getType()); + assertEquals(REDUNDANT, replicas.get(4L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(4L).get(server4).getType()); + assertEquals(BACKUP, replicas.get(5L).get(server1).getType()); + assertEquals(PRIMARY, replicas.get(5L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(5L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(6L).get(server1).getType()); + assertEquals(BACKUP, replicas.get(6L).get(server2).getType()); + assertEquals(BACKUP, replicas.get(6L).get(server3).getType()); + + servers.remove(server1); + servers.remove(server2); + clusterManager.redistributeProjects(replicas); + assertEquals(BACKUP, replicas.get(1L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(1L).get(server4).getType()); + assertEquals(BACKUP, replicas.get(2L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(2L).get(server4).getType()); + assertEquals(PRIMARY, replicas.get(3L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(3L).get(server4).getType()); + assertEquals(BACKUP, replicas.get(4L).get(server3).getType()); + assertEquals(PRIMARY, replicas.get(4L).get(server4).getType()); + assertEquals(PRIMARY, replicas.get(5L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(5L).get(server4).getType()); + assertEquals(PRIMARY, replicas.get(6L).get(server3).getType()); + assertEquals(BACKUP, replicas.get(6L).get(server4).getType()); + } +} \ No newline at end of file diff --git a/server-ee/server-ee-dashboard/pom.xml b/server-ee/server-ee-dashboard/pom.xml new file mode 100644 index 0000000000..edb0e2bfb3 --- /dev/null +++ b/server-ee/server-ee-dashboard/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + server-ee-dashboard + + io.onedev + server-ee + 8.1.0 + + + io.onedev.server.ee.dashboard.DashboardModule + + diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardModule.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardModule.java new file mode 100644 index 0000000000..a1a66453fb --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardModule.java @@ -0,0 +1,59 @@ +package io.onedev.server.ee.dashboard; + +import java.util.Collection; +import java.util.HashSet; + +import org.apache.wicket.protocol.http.WebApplication; + +import io.onedev.commons.loader.AbstractPluginModule; +import io.onedev.commons.loader.ImplementationProvider; +import io.onedev.commons.utils.ClassUtils; +import io.onedev.server.ee.dashboard.widgets.WidgetGroup; +import io.onedev.server.model.support.Widget; +import io.onedev.server.web.WebApplicationConfigurator; +import io.onedev.server.web.mapper.BasePageMapper; +import io.onedev.server.web.page.layout.MainMenuCustomization; + +/** + * NOTE: Do not forget to rename moduleClass property defined in the pom if you've renamed this class. + * + */ +public class DashboardModule extends AbstractPluginModule { + + @Override + protected void configure() { + super.configure(); + + // put your guice bindings here + bind(MainMenuCustomization.class).toInstance(new EEMainMenuCustomization()); + + contribute(ImplementationProvider.class, new ImplementationProvider() { + + @Override + public Collection> getImplementations() { + Collection> implementations = new HashSet<>(); + for (Class implementation: ClassUtils.findImplementations(Widget.class, WidgetGroup.class)) + implementations.add(implementation); + return implementations; + } + + @Override + public Class getAbstractClass() { + return Widget.class; + } + + }); + + contribute(WebApplicationConfigurator.class, new WebApplicationConfigurator() { + + @Override + public void configure(WebApplication application) { + application.mount(new BasePageMapper("dashboards", DashboardPage.class)); + application.mount(new BasePageMapper("dashboards/${dashboard}", DashboardPage.class)); + } + + }); + + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.html b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.html new file mode 100644 index 0000000000..fb8bbbf81b --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.html @@ -0,0 +1,56 @@ + +
+ + + + +
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+
+
+ + Add Widget + + Cancel +
+
+
+
+
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.java new file mode 100644 index 0000000000..e495b5ccd8 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardPage.java @@ -0,0 +1,629 @@ +package io.onedev.server.ee.dashboard; + +import java.lang.reflect.InvocationTargetException; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.SerializationUtils; +import org.apache.shiro.authz.UnauthorizedException; +import org.apache.wicket.Component; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.ajax.markup.html.form.AjaxButton; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.feedback.FencedFeedbackPanel; +import org.apache.wicket.markup.ComponentTag; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.head.OnLoadHeaderItem; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.link.Link; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.markup.repeater.RepeatingView; +import org.apache.wicket.model.AbstractReadOnlyModel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.PropertyModel; +import org.apache.wicket.request.mapper.parameter.PageParameters; + +import io.onedev.server.OneDev; +import io.onedev.server.ee.dashboard.widgets.ProjectListWidget; +import io.onedev.server.entitymanager.DashboardManager; +import io.onedev.server.entitymanager.DashboardVisitManager; +import io.onedev.server.model.Dashboard; +import io.onedev.server.model.DashboardGroupShare; +import io.onedev.server.model.DashboardUserShare; +import io.onedev.server.model.DashboardVisit; +import io.onedev.server.model.User; +import io.onedev.server.model.support.Widget; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.web.ajaxlistener.ConfirmClickListener; +import io.onedev.server.web.ajaxlistener.ConfirmLeaveListener; +import io.onedev.server.web.component.beaneditmodal.BeanEditModalPanel; +import io.onedev.server.web.component.floating.FloatingPanel; +import io.onedev.server.web.component.link.DropdownLink; +import io.onedev.server.web.component.svg.SpriteImage; +import io.onedev.server.web.component.typeselect.TypeSelectPanel; +import io.onedev.server.web.page.HomePage; +import io.onedev.server.web.page.layout.LayoutPage; + +@SuppressWarnings("serial") +public class DashboardPage extends LayoutPage { + + private static final String PARAM_DASHBOARD = "dashboard"; + + private static final int HORIZONTAL_CELL_COUNT = 48; + + private final IModel> dashboardsModel = new LoadableDetachableModel>() { + + @Override + protected List load() { + User user = getLoginUser(); + List dashboards = getDashboardManager().queryAccessible(user); + if (!dashboards.isEmpty()) { + Map dates = new HashMap<>(); + if (user != null) { + for (DashboardVisit visit: user.getDashboardVisits()) + dates.put(visit.getDashboard(), visit.getDate()); + } + Collections.sort(dashboards, new Comparator() { + + @Override + public int compare(Dashboard o1, Dashboard o2) { + Date date1 = dates.get(o1); + Date date2 = dates.get(o2); + if (date1 != null) { + if (date2 != null) + return date2.compareTo(date1); + else + return -1; + } else { + if (date2 != null) + return 1; + else + return o2.getId().compareTo(o1.getId()); + } + } + + }); + } + return dashboards; + } + + }; + + private final IModel activeDashboardModel; + + private Dashboard editingDashboard; + + private boolean failsafe; + + public DashboardPage(PageParameters params) { + super(params); + + Long activeDashboardId = params.get(PARAM_DASHBOARD).toOptionalLong(); + activeDashboardModel = new LoadableDetachableModel() { + + @Override + protected Dashboard load() { + List dashboards = getDashboards(); + if (activeDashboardId != null) { + Dashboard activeDashboard = getDashboardManager().load(activeDashboardId); + if (dashboards.contains(activeDashboard)) + return activeDashboard; + else + throw new UnauthorizedException(); + } else if (!dashboards.isEmpty()) { + return dashboards.iterator().next(); + } else { + return newDefaultDashboard(); + } + } + + }; + + failsafe = params.get(HomePage.PARAM_FAILSAFE).toBoolean(false); + } + + @Override + protected void onInitialize() { + super.onInitialize(); + editingDashboard = getActiveDashboard(); + add(newDashboardViewer()); + } + + private Component newDashboardViewer() { + Fragment fragment = new Fragment("content", "dashboardViewFrag", this) { + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + + response.render(OnLoadHeaderItem.forScript(String.format( + "onedev.server.dashboard.onLoad(%d); $(window).resize();", + HORIZONTAL_CELL_COUNT))); + } + + }; + fragment.add(AttributeAppender.append("class", "dashboard-viewer")); + Dashboard activeDashboard = getActiveDashboard(); + WebMarkupContainer dashboardSelector; + if (activeDashboard.isNew()) { + dashboardSelector = new WebMarkupContainer("dashboardSelector") { + + @Override + protected void onComponentTag(ComponentTag tag) { + super.onComponentTag(tag); + tag.setName("span"); + } + + }; + } else { + dashboardSelector = new DropdownLink("dashboardSelector") { + + @Override + protected Component newContent(String id, FloatingPanel dropdown) { + Fragment fragment = new Fragment(id, "dashboardSelectorFrag", DashboardPage.this); + fragment.add(new ListView("dashboards", new AbstractReadOnlyModel>() { + + @Override + public List getObject() { + return getDashboards(); + } + + }) { + + @Override + protected void populateItem(ListItem item) { + Dashboard dashboard = item.getModelObject(); + Link link = new Link("link") { + + @Override + public void onClick() { + Dashboard dashboard = item.getModelObject(); + if (getLoginUser() != null) + visit(dashboard); + setResponsePage(DashboardPage.class, paramsOf(dashboard, failsafe)); + } + + }; + + if (dashboard.equals(getActiveDashboard())) + link.add(new SpriteImage("icon", "tick")); + else + link.add(new WebMarkupContainer("icon")); + link.add(new Label("label", dashboard.getName())); + + if (getLoginUser() != null && !dashboard.getOwner().equals(getLoginUser())) + link.add(new Label("note", "Shared by " + dashboard.getOwner().getDisplayName())); + else + link.add(new WebMarkupContainer("note").setVisible(false)); + + item.add(link); + } + + }); + return fragment; + } + + }; + } + dashboardSelector.add(new Label("name", activeDashboard.getName())); + + String note; + if (getLoginUser() != null && !activeDashboard.getOwner().equals(getLoginUser())) + note = "shared by " + activeDashboard.getOwner().getDisplayName(); + else + note = null; + + if (note != null) + fragment.add(new Label("dashboardNote", note)); + else + fragment.add(new WebMarkupContainer("dashboardNote").setVisible(false)); + + fragment.add(dashboardSelector); + + fragment.add(new AjaxLink("editDashboard") { + + @Override + public void onClick(AjaxRequestTarget target) { + editingDashboard = getActiveDashboard(); + Component editor = newDashboardEditor(); + DashboardPage.this.replace(editor); + target.add(editor); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(getLoginUser() != null && getActiveDashboard().getOwner().equals(getLoginUser())); + } + + }); + fragment.add(new AjaxLink("copyDashboard") { + + @Override + public void onClick(AjaxRequestTarget target) { + editingDashboard = SerializationUtils.clone(getActiveDashboard()); + editingDashboard.setId(null); + Component editor = newDashboardEditor(); + DashboardPage.this.replace(editor); + target.add(editor); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(getLoginUser() != null && !getActiveDashboard().isNew()); + } + + }); + fragment.add(new AjaxLink("shareDashboard") { + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(getLoginUser() != null + && getActiveDashboard().getOwner().equals(getLoginUser()) + && !getActiveDashboard().isNew()); + } + + @Override + public void onClick(AjaxRequestTarget target) { + DashboardShareBean bean = new DashboardShareBean(); + for (DashboardGroupShare share: getActiveDashboard().getGroupShares()) + bean.getShareGroups().add(share.getGroup().getName()); + for (DashboardUserShare share: getActiveDashboard().getUserShares()) + bean.getShareUsers().add(share.getUser().getName()); + + bean.setForEveryone(getActiveDashboard().isForEveryone()); + + Set excludeProperties = new HashSet<>(); + if (!SecurityUtils.isAdministrator()) + excludeProperties.add(DashboardShareBean.PROP_FOR_EVERYONE); + new BeanEditModalPanel(target, bean, excludeProperties, + true, "Share Dashboard") { + + @Override + protected void onSave(AjaxRequestTarget target, DashboardShareBean bean) { + Dashboard dashboard = getActiveDashboard(); + + dashboard.setForEveryone(bean.isForEveryone()); + getDashboardManager().update(dashboard); + + getDashboardManager().syncShares(dashboard, bean.isForEveryone(), + bean.getShareGroups(), bean.getShareUsers()); + close(); + } + + }; + + } + + }); + fragment.add(new AjaxLink("deleteDashboard") { + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { + super.updateAjaxAttributes(attributes); + Dashboard dashboard = getActiveDashboard(); + if (dashboard.isForEveryone() + || !dashboard.getGroupShares().isEmpty() + || !dashboard.getUserShares().isEmpty()) { + attributes.getAjaxCallListeners().add(new ConfirmClickListener("" + + "This dashboard is currently being shared with others, " + + "do you really want to delete it?")); + } else { + attributes.getAjaxCallListeners().add(new ConfirmClickListener("" + + "Do you really want to delete this dashboard?")); + } + } + + @Override + public void onClick(AjaxRequestTarget target) { + getDashboardManager().delete(getActiveDashboard()); + setResponsePage(DashboardPage.class); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(getLoginUser() != null + && getActiveDashboard().getOwner().equals(getLoginUser()) + && !getActiveDashboard().isNew()); + } + + }); + fragment.add(new AjaxLink("addDashboard") { + + @Override + public void onClick(AjaxRequestTarget target) { + editingDashboard = new Dashboard(); + Component editor = newDashboardEditor(); + DashboardPage.this.replace(editor); + target.add(editor); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(getLoginUser() != null); + } + + }); + + RepeatingView widgetsView = new RepeatingView("widgets"); + for (Widget widget: activeDashboard.getWidgets()) + widgetsView.add(new WidgetPanel(widgetsView.newChildId(), widget, failsafe, null)); + + fragment.add(widgetsView); + + fragment.setOutputMarkupId(true); + return fragment; + } + + private void visit(Dashboard dashboard) { + DashboardVisit visit = getLoginUser().getDashboardVisit(dashboard); + if (visit == null) { + visit = new DashboardVisit(); + visit.setDashboard(dashboard); + visit.setUser(getLoginUser()); + getLoginUser().getDashboardVisits().add(visit); + } + visit.setDate(new Date()); + if (visit.isNew()) + getDashboardVisitManager().create(visit); + else + getDashboardVisitManager().update(visit); + } + + private DashboardManager getDashboardManager() { + return OneDev.getInstance(DashboardManager.class); + } + + private DashboardVisitManager getDashboardVisitManager() { + return OneDev.getInstance(DashboardVisitManager.class); + } + + private void markFormDirty(AjaxRequestTarget target, Form form) { + String script = String.format("onedev.server.form.markDirty($('#%s'));", form.getMarkupId()); + target.appendJavaScript(script); + } + + private WidgetPanel newWidgetPanel(Form form, RepeatingView widgetsView, Widget widget) { + return new WidgetPanel(widgetsView.newChildId(), widget, failsafe, new WidgetEditCallback() { + + @Override + public void onSave(AjaxRequestTarget target, WidgetPanel widgetPanel) { + markFormDirty(target, form); + } + + @Override + public void onDelete(AjaxRequestTarget target, WidgetPanel widgetPanel) { + editingDashboard.getWidgets().remove(widget); + markFormDirty(target, form); + widgetsView.remove(widgetPanel); + target.appendJavaScript(String.format("$('#%s').remove();", widgetPanel.getMarkupId())); + } + + @Override + public void onCopy(AjaxRequestTarget target, Widget widget) { + onWidgetAdded(target, form, widgetsView, widget); + } + + }); + } + + private Component newDashboardEditor() { + Fragment fragment = new Fragment("content", "dashboardEditFrag", this) { + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + + response.render(OnLoadHeaderItem.forScript(String.format( + "onedev.server.dashboard.onLoad(%d); $(window).resize();", + HORIZONTAL_CELL_COUNT))); + } + + }; + fragment.add(AttributeAppender.append("class", "dashboard-editor")); + fragment.setOutputMarkupId(true); + + Form form = new Form("form"); + IModel nameModel = new PropertyModel(editingDashboard, "name"); + form.add(new TextField("name", nameModel)); + + RepeatingView widgetsView = new RepeatingView("widgets"); + for (Widget widget: editingDashboard.getWidgets()) + widgetsView.add(newWidgetPanel(form, widgetsView, widget)); + + fragment.add(widgetsView); + + form.add(new DropdownLink("addWidget") { + + @Override + protected Component newContent(String id, FloatingPanel dropdown) { + return new TypeSelectPanel(id) { + + @Override + protected void onSelect(AjaxRequestTarget target, Class type) { + dropdown.close(); + + Widget widget; + try { + widget = type.getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException | NoSuchMethodException | SecurityException e) { + throw new RuntimeException(e); + } + new BeanEditModalPanel(target, widget) { + + @Override + protected void onSave(AjaxRequestTarget target, Widget bean) { + onWidgetAdded(target, form, widgetsView, bean); + close(); + } + + }; + } + + }; + } + + }); + + form.add(new AjaxButton("save") { + + @Override + protected void onSubmit(AjaxRequestTarget target, Form form) { + super.onSubmit(target, form); + + if (editingDashboard.getName() == null) { + error("Name is required"); + } else { + Dashboard dashboardWithSameName = getDashboardManager().find(getLoginUser(), editingDashboard.getName()); + if (dashboardWithSameName != null + && (editingDashboard.isNew() || !dashboardWithSameName.equals(editingDashboard))) { + error("This name is already been used by another dashboard under your account"); + } + } + + if (!hasErrorMessage()) { + if (editingDashboard.isNew()) { + editingDashboard.setOwner(getLoginUser()); + getDashboardManager().create(editingDashboard); + visit(editingDashboard); + setResponsePage(DashboardPage.class, paramsOf(editingDashboard, failsafe)); + } else { + Dashboard dashboard = getActiveDashboard(); + dashboard.setName(editingDashboard.getName()); + dashboard.setWidgets(editingDashboard.getWidgets()); + getDashboardManager().update(dashboard); + visit(dashboard); + setResponsePage(DashboardPage.class, paramsOf(dashboard, failsafe)); + } + } else { + target.add(form); + } + } + + }); + form.add(new AjaxLink("cancel") { + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { + super.updateAjaxAttributes(attributes); + attributes.getAjaxCallListeners().add(new ConfirmLeaveListener()); + } + + @Override + public void onClick(AjaxRequestTarget target) { + editingDashboard = new Dashboard(); + Component viewer = newDashboardViewer(); + DashboardPage.this.replace(viewer); + target.add(viewer); + } + + }); + form.add(new FencedFeedbackPanel("feedback", form)); + form.setOutputMarkupId(true); + + fragment.add(form); + + return fragment; + } + + private void initWidgetPosition(Widget widget) { + int top = 0; + while (true) { + for (int left = 0; left <= HORIZONTAL_CELL_COUNT - widget.getDefaultWidth(); left++) { + widget.setLeft(left); + widget.setTop(top); + widget.setRight(left + widget.getDefaultWidth()); + widget.setBottom(top + widget.getDefaultHeight()); + if (editingDashboard.getWidgets().stream().noneMatch(it->it.isIntersectedWith(widget))) + return; + } + top++; + } + } + + private void onWidgetAdded(AjaxRequestTarget target, Form form, RepeatingView widgetsView, Widget widget) { + initWidgetPosition(widget); + editingDashboard.getWidgets().add(widget); + markFormDirty(target, form); + WidgetPanel widgetPanel = newWidgetPanel(form, widgetsView, widget); + widgetsView.add(widgetPanel); + target.prependJavaScript(String.format( + "$('.dashboard>.body>.content').append('
');", + widgetPanel.getMarkupId())); + target.add(widgetPanel); + target.appendJavaScript(String.format("onedev.server.dashboard.onWidgetAdded('%s');", + widgetPanel.getMarkupId())); + } + + private Dashboard newDefaultDashboard() { + Dashboard dashboard = new Dashboard(); + ProjectListWidget widget = new ProjectListWidget(); + widget.setTitle("Projects"); + widget.setLeft(0); + widget.setRight(HORIZONTAL_CELL_COUNT); + widget.setTop(0); + widget.setBottom(16); + dashboard.getWidgets().add(widget); + dashboard.setName("Default"); + dashboard.setOwner(getLoginUser()); + return dashboard; + } + + private List getDashboards() { + return dashboardsModel.getObject(); + } + + private Dashboard getActiveDashboard() { + return activeDashboardModel.getObject(); + } + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + response.render(JavaScriptHeaderItem.forReference(new DashboardResourceReference())); + } + + @Override + protected void onDetach() { + dashboardsModel.detach(); + activeDashboardModel.detach(); + super.onDetach(); + } + + @Override + protected Component newTopbarTitle(String componentId) { + return new Label(componentId, "Dashboards"); + } + + public static PageParameters paramsOf(@Nullable Dashboard dashboard, boolean failsafe) { + PageParameters params = new PageParameters(); + if (dashboard != null) + params.add(PARAM_DASHBOARD, dashboard.getId()); + if (failsafe) + params.add(HomePage.PARAM_FAILSAFE, true); + return params; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardResourceReference.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardResourceReference.java new file mode 100644 index 0000000000..9c244db46d --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardResourceReference.java @@ -0,0 +1,32 @@ +package io.onedev.server.ee.dashboard; + +import java.util.List; + +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.HeaderItem; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; + +import io.onedev.server.web.asset.jqueryui.JQueryUIResourceReference; +import io.onedev.server.web.asset.snapsvg.SnapSvgResourceReference; +import io.onedev.server.web.page.base.BaseDependentCssResourceReference; +import io.onedev.server.web.page.base.BaseDependentResourceReference; + +public class DashboardResourceReference extends BaseDependentResourceReference { + + private static final long serialVersionUID = 1L; + + public DashboardResourceReference() { + super(DashboardResourceReference.class, "dashboard.js"); + } + + @Override + public List getDependencies() { + List dependencies = super.getDependencies(); + dependencies.add(JavaScriptHeaderItem.forReference(new SnapSvgResourceReference())); + dependencies.add(JavaScriptHeaderItem.forReference(new JQueryUIResourceReference())); + dependencies.add(CssHeaderItem.forReference(new BaseDependentCssResourceReference( + DashboardResourceReference.class, "dashboard.css"))); + return dependencies; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardShareBean.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardShareBean.java new file mode 100644 index 0000000000..d544332e12 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/DashboardShareBean.java @@ -0,0 +1,126 @@ +package io.onedev.server.ee.dashboard; + +import edu.emory.mathcs.backport.java.util.Collections; +import io.onedev.server.OneDev; +import io.onedev.server.annotation.ChoiceProvider; +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ShowCondition; +import io.onedev.server.entitymanager.GroupManager; +import io.onedev.server.entitymanager.MembershipManager; +import io.onedev.server.entitymanager.UserManager; +import io.onedev.server.model.Group; +import io.onedev.server.model.User; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.util.EditContext; +import io.onedev.server.util.facade.UserFacade; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +@Editable +public class DashboardShareBean implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String PROP_FOR_EVERYONE = "forEveryone"; + + private boolean forEveryone; + + private List shareGroups = new ArrayList<>(); + + private List shareUsers = new ArrayList<>(); + + @Editable(order=100, name="To Everyone") + public boolean isForEveryone() { + return forEveryone; + } + + public void setForEveryone(boolean forEveryone) { + this.forEveryone = forEveryone; + } + + @SuppressWarnings("unused") + private static boolean isNotForEveryone() { + return !(boolean) EditContext.get().getInputValue("forEveryone"); + } + + @Editable(order=200, name="Share with Groups", descriptionProvider="getShareGroupsDescription") + @ShowCondition("isNotForEveryone") + @ChoiceProvider("getGroupChoices") + public List getShareGroups() { + return shareGroups; + } + + public void setShareGroups(List shareGroups) { + this.shareGroups = shareGroups; + } + + @SuppressWarnings("unused") + private static String getShareGroupsDescription() { + if (SecurityUtils.isAdministrator()) { + return "Share dashboard with specified groups"; + } else { + return "Share this dashboard with all members of specified groups. Note that as a non-" + + "admin user you can only share with groups you are currently a member of"; + } + } + + @SuppressWarnings("unused") + private static List getGroupChoices() { + List groups = new ArrayList<>(); + if (SecurityUtils.isAdministrator()) { + for (Group group: OneDev.getInstance(GroupManager.class).query()) + groups.add(group.getName()); + } else { + for (Group group: SecurityUtils.getUser().getGroups()) + groups.add(group.getName()); + } + Collections.sort(groups); + + return groups; + } + + @Editable(order=300, name="Share with Users", descriptionProvider="getShareUsersDescription") + @ShowCondition("isNotForEveryone") + @ChoiceProvider("getUserChoices") + public List getShareUsers() { + return shareUsers; + } + + public void setShareUsers(List shareUsers) { + this.shareUsers = shareUsers; + } + + @SuppressWarnings("unused") + private static String getShareUsersDescription() { + if (SecurityUtils.isAdministrator()) { + return "Share dashboard with specified users"; + } else { + return "Share this dashboard with specified users. Note that as a non-admin user you " + + "can only share with members of groups you are currently a member of"; + } + } + + @SuppressWarnings("unused") + private static List getUserChoices() { + Set users = new TreeSet<>(); + if (SecurityUtils.isAdministrator()) { + for (UserFacade user: OneDev.getInstance(UserManager.class).cloneCache().values()) { + if (user.getId() > 0) + users.add(user.getName()); + } + } else { + for (User user: OneDev.getInstance(MembershipManager.class).queryMembers(SecurityUtils.getUser())) + users.add(user.getName()); + } + users.remove(SecurityUtils.getUser().getName()); + + List choices = new ArrayList<>(users); + Collections.sort(choices); + return choices; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/EEMainMenuCustomization.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/EEMainMenuCustomization.java new file mode 100644 index 0000000000..aaccc7e11d --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/EEMainMenuCustomization.java @@ -0,0 +1,32 @@ +package io.onedev.server.ee.dashboard; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.wicket.core.request.handler.PageProvider; +import org.apache.wicket.request.mapper.parameter.PageParameters; + +import io.onedev.server.web.page.layout.DefaultMainMenuCustomization; +import io.onedev.server.web.page.layout.SidebarMenuItem; + +public class EEMainMenuCustomization extends DefaultMainMenuCustomization { + + private static final long serialVersionUID = 1L; + + @Override + public PageProvider getHomePage(boolean failsafe) { + return new PageProvider(DashboardPage.class, DashboardPage.paramsOf(null, failsafe)); + } + + @Override + public List getMainMenuItems() { + List menuItems = new ArrayList<>(); + + menuItems.add(new SidebarMenuItem.Page("dashboard", "Dashboards", DashboardPage.class, + new PageParameters())); + menuItems.addAll(super.getMainMenuItems()); + + return menuItems; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetEditCallback.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetEditCallback.java new file mode 100644 index 0000000000..ef3b951141 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetEditCallback.java @@ -0,0 +1,17 @@ +package io.onedev.server.ee.dashboard; + +import java.io.Serializable; + +import org.apache.wicket.ajax.AjaxRequestTarget; + +import io.onedev.server.model.support.Widget; + +interface WidgetEditCallback extends Serializable { + + void onSave(AjaxRequestTarget target, WidgetPanel widgetPanel); + + void onCopy(AjaxRequestTarget target, Widget widget); + + void onDelete(AjaxRequestTarget target, WidgetPanel widgetPanel); + +} \ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.html b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.html new file mode 100644 index 0000000000..b4acdb5bbf --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.html @@ -0,0 +1,9 @@ + +
+ + + +
+
+
+
\ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.java new file mode 100644 index 0000000000..fb92be5b72 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/WidgetPanel.java @@ -0,0 +1,253 @@ +package io.onedev.server.ee.dashboard; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.SerializationUtils; +import org.apache.wicket.Component; +import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.CallbackParameter; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.OnDomReadyHeaderItem; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.model.AbstractReadOnlyModel; +import org.apache.wicket.request.IRequestParameters; +import org.apache.wicket.request.cycle.RequestCycle; + +import io.onedev.server.model.support.Widget; +import io.onedev.server.web.behavior.AbstractPostAjaxBehavior; +import io.onedev.server.web.component.beaneditmodal.BeanEditModalPanel; +import io.onedev.server.web.component.floating.FloatingPanel; +import io.onedev.server.web.component.menu.MenuItem; +import io.onedev.server.web.component.menu.MenuLink; + +@SuppressWarnings("serial") +class WidgetPanel extends Panel { + + private final Widget widget; + + private final boolean failsafe; + + private final WidgetEditCallback editCallback; + + private AbstractDefaultAjaxBehavior callbackBehavior; + + public WidgetPanel(String id, Widget widget, boolean failsafe, @Nullable WidgetEditCallback editCallback) { + super(id); + this.widget = widget; + this.failsafe = failsafe; + this.editCallback = editCallback; + } + + @Override + protected void onInitialize() { + super.onInitialize(); + + add(new Label("title", new AbstractReadOnlyModel() { + + @Override + public String getObject() { + return widget.getTitle(); + } + + }).setOutputMarkupId(true)); + + add(new MenuLink("actions") { + + @Override + protected List getMenuItems(FloatingPanel dropdown) { + List menuItems = new ArrayList<>(); + + menuItems.add(new MenuItem() { + + @Override + public String getLabel() { + return "Refresh"; + } + + @Override + public WebMarkupContainer newLink(String id) { + return new AjaxLink(id) { + + @Override + public void onClick(AjaxRequestTarget target) { + dropdown.close(); + + Component body = widget.render("body", failsafe); + WidgetPanel.this.replace(body); + target.add(body); + } + + }; + } + + }); + + menuItems.add(new MenuItem() { + + @Override + public String getLabel() { + return "Edit"; + } + + @Override + public WebMarkupContainer newLink(String id) { + return new AjaxLink(id) { + + @Override + public void onClick(AjaxRequestTarget target) { + dropdown.close(); + + new BeanEditModalPanel(target, widget) { + + @Override + protected void onSave(AjaxRequestTarget target, Widget bean) { + target.add(WidgetPanel.this.get("title")); + Component body = widget.render("body", failsafe); + WidgetPanel.this.replace(body); + target.add(body); + + editCallback.onSave(target, WidgetPanel.this); + close(); + } + + }; + } + + }; + } + + }); + + menuItems.add(new MenuItem() { + + @Override + public String getLabel() { + return "Copy"; + } + + @Override + public WebMarkupContainer newLink(String id) { + return new AjaxLink(id) { + + @Override + public void onClick(AjaxRequestTarget target) { + dropdown.close(); + + new BeanEditModalPanel(target, SerializationUtils.clone(widget)) { + + @Override + protected void onSave(AjaxRequestTarget target, Widget bean) { + target.add(WidgetPanel.this); + editCallback.onCopy(target, bean); + close(); + } + + }; + } + + }; + } + + }); + + menuItems.add(new MenuItem() { + + @Override + public String getLabel() { + return "Delete"; + } + + @Override + public WebMarkupContainer newLink(String id) { + return new AjaxLink(id) { + + @Override + public void onClick(AjaxRequestTarget target) { + dropdown.close(); + editCallback.onDelete(target, WidgetPanel.this); + } + + }; + } + + }); + + return menuItems; + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(editCallback != null); + } + + }); + + add(new AjaxLink("refresh") { + + @Override + public void onClick(AjaxRequestTarget target) { + Component body = widget.render("body", failsafe); + WidgetPanel.this.replace(body); + target.add(body); + } + + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(editCallback == null); + } + + }); + + add(widget.render("body", failsafe).setOutputMarkupId(true)); + + if (editCallback != null) { + add(callbackBehavior = new AbstractPostAjaxBehavior() { + + @Override + protected void respond(AjaxRequestTarget target) { + IRequestParameters params = RequestCycle.get().getRequest().getPostParameters(); + widget.setLeft(params.getParameterValue("left").toInt()); + widget.setTop(params.getParameterValue("top").toInt()); + widget.setRight(params.getParameterValue("right").toInt()); + widget.setBottom(params.getParameterValue("bottom").toInt()); + } + + }); + } + + setOutputMarkupId(true); + } + + public Widget getWidget() { + return widget; + } + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + + CharSequence callback; + if (callbackBehavior != null) { + callback = callbackBehavior.getCallbackFunction( + CallbackParameter.explicit("left"), + CallbackParameter.explicit("top"), + CallbackParameter.explicit("right"), + CallbackParameter.explicit("bottom")); + } else { + callback = "undefined"; + } + String script = String.format("onedev.server.dashboard.onWidgetDomReady('%s', %d, %d, %d, %d, %s);", + getMarkupId(), widget.getLeft(), widget.getTop(), widget.getRight(), widget.getBottom(), callback); + response.render(OnDomReadyHeaderItem.forScript(script)); + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.css b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.css new file mode 100644 index 0000000000..8228a8c9f0 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.css @@ -0,0 +1,128 @@ +.DashboardPage .topbar { + box-shadow: none; + border-bottom: 1px solid var(--light-gray); +} +.dark-mode.DashboardPage .topbar { + border-bottom-color: var(--dark-mode-lighter-dark); +} + +.dashboard>.head { + background: white; + box-shadow: 0px 0px 4px 0px rgba(0,0,0,0.1); +} +.dashboard>.head .feedbackPanel { + margin-bottom: 0; +} +.dark-mode .dashboard>.head { + background: var(--dark-mode-dark); + box-shadow: 0 0 12px rgb(0 0 0 / 50%); + color: white; +} +.dark-mode .dashboard { + background: var(--dark-mode-darker); +} + +.dashboard>.head>span.dashboard-selector>svg { + display: none; +} +.dashboard>.head { + padding: 0.6rem 1.2rem; +} +.dashboard>.head .feedbackPanelERROR { + background: none; + border: none; + padding: 0; +} +.dashboard>.body>.content>.grid { + width: 100%; +} + +.dashboard>.body>.content>.widget { + border-radius: 0.42rem; + display: none; + background: white; + position: absolute; + left: 0; + top: 0; + z-index: 1; +} +.dark-mode .dashboard>.body>.content>.widget { + background: var(--dark-mode-dark); +} + +.dashboard.dashboard-editor>.body>.content>.widget { + border: 1px solid var(--light-gray); +} +.dashboard>.body>.content>.widget>.head .refresh { + display: none; +} +.dashboard>.body>.content>.widget:hover>.head .refresh { + display: inline; +} +.dark-mode .dashboard.dashboard-editor>.body>.content>.widget { + border-color: var(--dark-mode-light-dark); +} + +.dashboard>.body>.content>.widget>.head { + border-bottom: 1px solid var(--light-gray); + padding: 0.6rem 1.2rem; + font-weight: 600; + font-size: 1.2rem; +} +.dark-mode .dashboard>.body>.content>.widget>.head { + border-color: var(--dark-mode-light-dark); +} + +.dashboard.dashboard-editor>.body>.content { + margin: 0.5rem; +} +.dashboard.dashboard-viewer>.body>.content { + margin: calc(0.5rem - 6px); + margin-bottom: 0.5rem; +} +@media (min-width: 576px) { +.dashboard.dashboard-editor>.body>.content { + margin: 2rem; +} +.dashboard.dashboard-viewer>.body>.content { + margin: calc(2rem - 6px); + margin-bottom: 2rem; +} +} + +.dashboard.dashboard-editor>.body>.content>.widget { + cursor: move; +} +.dashboard.dashboard-editor>.body>.content>.widget .ui-icon-gripsmall-diagonal-se { + background-image: none; +} +.dashboard.dashboard-editor>.body>.content>.widget.ui-resizable-resizing { + z-index: 10; +} +.dashboard>.body>.content>.widget>.body { + padding: 1rem 1.6rem; +} +.dashboard.dashboard-editor>.body>.content>.widget>.body { + margin-bottom: 12px; +} +.dashboard.dashboard-editor>.body>.content>.widget { + background: white url(/img/resize.png) no-repeat right 2px bottom 2px; + background-size: 8px 8px; +} +.dark-mode .dashboard.dashboard-editor>.body>.content>.widget { + background: var(--dark-mode-dark) url(/img/resize-dark.png) no-repeat right 2px bottom 2px; + background-size: 8px 8px; +} +.dashboard.dashboard-editor>.body>.content>.widget>.head>.title { + margin-right: 0.4rem !important; +} +.dashboard.dashboard-editor>.body>.content>.widget>.head>.grip { + display: inline !important; +} + +.dashboard .card { + box-shadow: none; +} +.dashboard .card-body { + padding: 0; +} \ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.js b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.js new file mode 100644 index 0000000000..061f10f912 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/dashboard.js @@ -0,0 +1,337 @@ +onedev.server.dashboard = { + xCellCount: 0, + cellMargin: 6, + cellHeight: 28, + getCellWidth: function() { + var xCellCount = onedev.server.dashboard.xCellCount; + var cellMargin = onedev.server.dashboard.cellMargin; + return ($(".dashboard>.body>.content>.grid").width() - (xCellCount+1)*cellMargin) / xCellCount; + }, + placeWidgets: function() { + /* + * Reposition all widgets as long as there are something changed as add/move/resize of one widget + * may affect other widgets, for instance, it may cause scrollbar to appear + */ + var cellMargin = onedev.server.dashboard.cellMargin; + var cellHeight = onedev.server.dashboard.cellHeight; + var cellWidth = onedev.server.dashboard.getCellWidth(); + var bottomMost = onedev.server.dashboard.getBottomMost() * (cellHeight + cellMargin); + + $(".dashboard>.body>.content>.widget").each(function() { + var $widget = $(this); + var left = $widget.data("left") * (cellWidth + cellMargin) + cellMargin; + var top = $widget.data("top") * (cellHeight + cellMargin) + cellMargin; + var right = $widget.data("right") * (cellWidth + cellMargin); + var bottom = $widget.data("bottom") * (cellHeight + cellMargin); + + $widget.outerWidth(right - left); + var height = bottom - top; + if ($(".dashboard").hasClass("dashboard-editor")) { + $widget.outerHeight(height); + } else { + var hasBeneathWidgets = false; + var $body = $(".dashboard>.body"); + var $content = $body.children(".content"); + $content.children(".widget").each(function() { + if (this != $widget[0] + && $(this).data("bottom") > $widget.data("bottom") + && $(this).data("right") > $widget.data("left") + && $(this).data("left") < $widget.data("right")) { + hasBeneathWidgets = true; + return false; + } + }); + + if (!hasBeneathWidgets) { + var marginTop = $content.css("margin-top"); + marginTop = parseInt(marginTop.substring(0, marginTop.length-2)); + var marginBottom = $content.css("margin-bottom"); + marginBottom = parseInt(marginBottom.substring(0, marginBottom.length-2)); + var screenBottom = $body.height() - marginTop - marginBottom; + if (bottomMost > screenBottom) + $widget.outerHeight(bottomMost - top); + else + $widget.outerHeight(screenBottom - top); + } else { + $widget.outerHeight(height); + } + } + + $widget.css({ + left: left, + top: top + }).show(); + }); + }, + drawAlignGrid: function() { + var $grid = $(".dashboard>.body>.content>.grid"); + + var xCellCount = onedev.server.dashboard.xCellCount; + var cellMargin = onedev.server.dashboard.cellMargin; + var cellHeight = onedev.server.dashboard.cellHeight; + var cellWidth = onedev.server.dashboard.getCellWidth(); + + var paper = Snap($grid[0]); + + $grid.empty(); + + for (var i=0; i<=xCellCount; i++) { + var left = (cellWidth + cellMargin) * i + cellMargin/2; + var line = paper.line(left, 0, left, $grid.height()); + line.attr({ + stroke: onedev.server.isDarkMode()? "#1e1e2d": "white", + strokeWidth: cellMargin + }); + } + + var top = cellMargin/2; + while (top < $grid.height() - cellMargin/2) { + var line = paper.line(0, top, $grid.width(), top); + line.attr({ + stroke: onedev.server.isDarkMode()? "#1e1e2d": "white", + strokeWidth: cellMargin + }); + top += cellHeight + cellMargin; + } + }, + getBottomMost: function() { + var bottomMost = 0; + $(".dashboard>.body>.content>.widget").each(function() { + if ($(this).data("bottom") > bottomMost) + bottomMost = $(this).data("bottom"); + }); + return bottomMost; + }, + adjustGridHeight: function() { + var $grid = $(".dashboard>.body>.content>.grid"); + + var bottomMost = onedev.server.dashboard.getBottomMost(); + + if ($(".dashboard").hasClass("dashboard-editor")) + bottomMost += 8; + + var expectedHeight = bottomMost * (onedev.server.dashboard.cellHeight + onedev.server.dashboard.cellMargin); + if ($grid.height() != expectedHeight) { + $grid.height(expectedHeight); + return true; + } else { + return false; + } + }, + onLoad: function(xCellCount) { + onedev.server.dashboard.xCellCount = xCellCount; + + onedev.server.dashboard.adjustGridHeight(); + + var editMode = $(".dashboard").hasClass("dashboard-editor"); + if (editMode) + onedev.server.dashboard.drawAlignGrid(); + + onedev.server.dashboard.placeWidgets(); + + $(".dashboard>.body>.content").on("resized", function() { + setTimeout(function() { + if (editMode) + onedev.server.dashboard.drawAlignGrid(); + onedev.server.dashboard.placeWidgets(); + }); + }); + }, + onWidgetDomReady: function(widgetId, left, top, right, bottom, callback) { + var $widget = $("#" + widgetId); + $widget.data("left", left).data("top", top).data("right", right).data("bottom", bottom).data("callback", callback); + if (callback) { + var initialWidth, initialHeight; + $widget.resizable({ + minWidth: 100, + minHeight: 80, + start: function() { + initialWidth = $widget.outerWidth(); + initialHeight = $widget.outerHeight(); + }, + stop: function() { + var $content = $(".dashboard>.body>.content"); + var $grid = $content.children(".grid"); + var left = $widget.offset().left - $grid.offset().left; + var top = $widget.offset().top - $grid.offset().top; + var coordination = onedev.server.dashboard.getCoordination($widget, { + left: left, + top: top, + right: left + $widget.outerWidth(), + bottom: top + $widget.outerHeight() + }, false); + if (coordination) { + $widget.data("left", coordination.left).data("top", coordination.top) + .data("right", coordination.right).data("bottom", coordination.bottom); + if (onedev.server.dashboard.adjustGridHeight()) + onedev.server.dashboard.drawAlignGrid(); + onedev.server.dashboard.placeWidgets(); + $widget.find(".resize-aware").trigger("resized"); + $widget.data("callback")(coordination.left, coordination.top, coordination.right, coordination.bottom); + onedev.server.form.markDirty($content.closest(".body").prev().find("form")); + } else { + $widget.addClass("ui-resizable-resizing"); + $widget.effect("size", { + to: { + width: initialWidth, + height: initialHeight + } + }, 250, function() { + $widget.removeClass("ui-resizable-resizing"); + }); + } + } + }); + + $widget.on("resize", function(e) { + e.stopPropagation(); + }); + } + }, + getCellSpan: function(position, cellSize) { + var cellMargin = onedev.server.dashboard.cellMargin; + var count = Math.floor(position / (cellSize + cellMargin)); + if (Math.abs(position - count * (cellSize + cellMargin)) > Math.abs(position - (count + 1) * (cellSize + cellMargin))) + return count + 1; + else + return count; + }, + onWidgetAdded: function(widgetId) { + var $widget = $("#" + widgetId); + if (onedev.server.dashboard.adjustGridHeight()) + onedev.server.dashboard.drawAlignGrid(); + onedev.server.dashboard.placeWidgets(); + $widget[0].scrollIntoViewIfNeeded(false); + $widget.effect("bounce", {distance: 10}); + }, + isRectIntersected: function(rect1, rect2) { + return !(rect2.left >= rect1.right || rect2.right <= rect1.left || rect2.top >= rect1.bottom || rect2.bottom <= rect1.top); + }, + getCoordination: function($widget, rect, move) { + var $content = $(".dashboard>.body>.content"); + + var left = rect.left; + var top = rect.top; + + var right = rect.right; + var bottom = rect.bottom; + + if (left < 0) { + right -= left; + left = 0; + } + + if (top < 0) { + bottom -= top; + top = 0; + } + + var maxWidth = $content.outerWidth(); + var maxHeight = $content.outerHeight(); + + if (right > maxWidth) + right = maxWidth; + if (bottom > maxHeight) + bottom = maxHeight; + + right = onedev.server.dashboard.getCellSpan(right, onedev.server.dashboard.getCellWidth()); + bottom = onedev.server.dashboard.getCellSpan(bottom, onedev.server.dashboard.cellHeight); + + if (move) { + left = right - $widget.data("right") + $widget.data("left"); + top = bottom - $widget.data("bottom") + $widget.data("top"); + } else { + left = $widget.data("left"); + top = $widget.data("top"); + } + + if ($widget.data("left") != left || $widget.data("top") != top + || $widget.data("right") != right || $widget.data("bottom") != bottom) { + + var hasIntersection = false; + $(".dashboard>.body>.content>.widget").each(function() { + if (this != $widget[0]) { + var thisRect = { + left: $(this).data("left"), + top: $(this).data("top"), + right: $(this).data("right"), + bottom: $(this).data("bottom") + }; + var widgetRect = { + left: left, + top: top, + right: right, + bottom: bottom + } + if (onedev.server.dashboard.isRectIntersected(thisRect, widgetRect)) { + hasIntersection = true; + return false; + } + } + }); + + if (!hasIntersection) { + return { + left: left, + top: top, + right: right, + bottom: bottom + } + } + } + }, + getMoveRect: function(event) { + var widgetData = onedev.server.dashboard.draggingWidgetData; + var $widget = $("#" + widgetData.id); + var $grid = $(".dashboard>.body>.content>.grid"); + + var left = event.pageX - $grid.offset().left - widgetData.offsetX; + var top = event.pageY - $grid.offset().top - widgetData.offsetY; + + return { + left: left, + top: top, + right: left + $widget.outerWidth(), + bottom: top + $widget.outerHeight() + } + }, + onDragStart: function(event) { + event.dataTransfer.setData("widget", ""); + onedev.server.dashboard.draggingWidgetData = { + id: event.target.id, + offsetX: event.offsetX, + offsetY: event.offsetY + }; + event.dataTransfer.effectAllowed = "move"; + }, + onDragOver: function(event) { + if (event.dataTransfer.types.includes("widget")) { + event.dataTransfer.dropEffect = "move"; + + var widgetData = onedev.server.dashboard.draggingWidgetData; + var $widget = $("#" + widgetData.id); + + if (onedev.server.dashboard.getCoordination($widget, onedev.server.dashboard.getMoveRect(event), true)) + event.preventDefault(); + } + }, + onDrop: function(event) { + if (event.dataTransfer.types.includes("widget")) { + var $widget = $("#" + onedev.server.dashboard.draggingWidgetData.id); + var coordination = onedev.server.dashboard.getCoordination($widget, onedev.server.dashboard.getMoveRect(event), true); + if (coordination) { + $widget.data("left", coordination.left).data("top", coordination.top) + .data("right", coordination.right).data("bottom", coordination.bottom); + if (onedev.server.dashboard.adjustGridHeight()) + onedev.server.dashboard.drawAlignGrid(); + onedev.server.dashboard.placeWidgets(); + + $widget.data("callback")(coordination.left, coordination.top, coordination.right, coordination.bottom); + var $content = $(".dashboard>.body>.content"); + onedev.server.form.markDirty($content.closest(".body").prev().find("form")); + + event.preventDefault(); + } + } + } +} \ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/BuildListWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/BuildListWidget.java new file mode 100644 index 0000000000..866735bed4 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/BuildListWidget.java @@ -0,0 +1,125 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.OneDev; +import io.onedev.server.annotation.BuildQuery; +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectChoice; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Project; +import io.onedev.server.model.support.Widget; +import io.onedev.server.security.permission.AccessProject; +import io.onedev.server.web.component.build.list.BuildListPanel; +import org.apache.wicket.Component; +import org.apache.wicket.model.Model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Editable(name="Build List", order=300) +public class BuildListWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + private String baseQuery; + + private boolean showJob = true; + + private boolean showRef = true; + + @Editable(order=100, name="Project", placeholder="All accessible", description="Optionally specify project to " + + "show builds of. Leave empty to show builds of all projects with permissions") + @ProjectChoice("getPermittedProjects") + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new AccessProject())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Editable(order=200, placeholder="All builds", description="Optionally specify base query of the list") + @BuildQuery(withCurrentUserCriteria=true) + public String getBaseQuery() { + return baseQuery; + } + + public void setBaseQuery(String baseQuery) { + this.baseQuery = baseQuery; + } + + @Editable(order=300, description="Whether or not to show job column") + public boolean isShowJob() { + return showJob; + } + + public void setShowJob(boolean showJob) { + this.showJob = showJob; + } + + @Editable(order=300, name="Show branch/tag", description="Whether or not to show branch/tag column") + public boolean isShowRef() { + return showRef; + } + + public void setShowRef(boolean showRef) { + this.showRef = showRef; + } + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @Override + protected Component doRender(String componentId) { + Long projectId; + if (projectPath != null) { + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else + projectId = project.getId(); + } else { + projectId = null; + } + return new BuildListPanel(componentId, Model.of((String)null), showJob, showRef, 0) { + + private static final long serialVersionUID = 1L; + + @Override + protected Project getProject() { + if (projectId != null) + return getProjectManager().load(projectId); + else + return null; + } + + @Override + protected io.onedev.server.search.entity.build.BuildQuery getBaseQuery() { + return io.onedev.server.search.entity.build.BuildQuery.parse(getProject(), baseQuery, true, true); + } + + }; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/IssueListWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/IssueListWidget.java new file mode 100644 index 0000000000..f547259fd2 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/IssueListWidget.java @@ -0,0 +1,109 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.OneDev; +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.IssueQuery; +import io.onedev.server.annotation.ProjectChoice; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Project; +import io.onedev.server.model.support.Widget; +import io.onedev.server.search.entity.issue.IssueQueryParseOption; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.security.permission.AccessProject; +import io.onedev.server.web.component.issue.list.IssueListPanel; +import org.apache.wicket.Component; +import org.apache.wicket.model.Model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Editable(name="Issue List", order=200) +public class IssueListWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + private String baseQuery; + + @Editable(order=100, name="Project", placeholder="All accessible", description="Optionally specify project " + + "to show issues of. Leave empty to show issues of all accessible projects") + @ProjectChoice("getPermittedProjects") + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new AccessProject())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Editable(order=200, placeholder="All issues", description="Optionally specify base query of the list") + @IssueQuery(withCurrentUserCriteria=true) + public String getBaseQuery() { + return baseQuery; + } + + public void setBaseQuery(String baseQuery) { + this.baseQuery = baseQuery; + } + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @Override + protected Component doRender(String componentId) { + Long projectId; + if (projectPath != null) { + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else if (!SecurityUtils.canAccess(project)) + throw new ExplicitException("Permission denied"); + else + projectId = project.getId(); + } else { + projectId = null; + } + return new IssueListPanel(componentId, Model.of((String)null)) { + + private static final long serialVersionUID = 1L; + + @Override + protected Project getProject() { + if (projectId != null) + return getProjectManager().load(projectId); + else + return null; + } + + @Override + protected io.onedev.server.search.entity.issue.IssueQuery getBaseQuery() { + IssueQueryParseOption option = new IssueQueryParseOption(); + option.withCurrentUserCriteria(true); + return io.onedev.server.search.entity.issue.IssueQuery.parse(getProject(), baseQuery, option, true); + } + + }; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownBlobWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownBlobWidget.java new file mode 100644 index 0000000000..6c20e3a608 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownBlobWidget.java @@ -0,0 +1,331 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.commons.utils.PlanarRange; +import io.onedev.server.OneDev; +import io.onedev.server.annotation.BlobChoice; +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectChoice; +import io.onedev.server.annotation.RevisionChoice; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.git.Blob; +import io.onedev.server.git.BlobIdent; +import io.onedev.server.model.CodeComment; +import io.onedev.server.model.Project; +import io.onedev.server.model.PullRequest; +import io.onedev.server.model.support.Widget; +import io.onedev.server.search.code.hit.QueryHit; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.security.permission.ReadCode; +import io.onedev.server.util.EditContext; +import io.onedev.server.util.ProjectScopedCommit; +import io.onedev.server.web.component.markdown.MarkdownViewer; +import io.onedev.server.web.page.project.blob.ProjectBlobPage; +import io.onedev.server.web.page.project.blob.render.BlobRenderContext; +import io.onedev.server.web.util.FileUpload; +import org.apache.commons.lang3.StringUtils; +import org.apache.wicket.Component; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.model.Model; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; + +import javax.validation.constraints.NotEmpty; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Editable(name="Markdown from File", order=10010) +public class MarkdownBlobWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + private String revision; + + private String filePath; + + @Editable(order=100, name="Project") + @ProjectChoice("getPermittedProjects") + @NotEmpty + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new ReadCode())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Editable(order=200) + @RevisionChoice("getCurrentProject") + @NotEmpty + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + + private static Project getCurrentProject() { + String projectPath = (String) EditContext.get().getInputValue("projectPath"); + if (projectPath != null) + return getProjectManager().findByPath(projectPath); + else + return null; + } + + @Editable(order=300) + @BlobChoice(commitProvider="getCurrentCommit", patterns="**/*.md **/*.MD") + @NotEmpty + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @SuppressWarnings("unused") + private static ProjectScopedCommit getCurrentCommit() { + Project project = getCurrentProject(); + if (project != null) { + String revision = (String) EditContext.get().getInputValue("revision"); + if (revision != null) + return new ProjectScopedCommit(project, project.getRevCommit(revision, true)); + else + return null; + } else { + return null; + } + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @Override + protected Component doRender(String componentId) { + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else if (!SecurityUtils.canReadCode(project)) + throw new ExplicitException("Permission denied"); + + Long projectId = project.getId(); + + BlobIdent blobIdent = new BlobIdent(revision, filePath); + Blob blob = project.getBlob(blobIdent, false); + if (blob == null) { + String message = String.format("File not found (revision: %s, file: %s)", revision, filePath); + throw new ExplicitException(message); + } + + return new MarkdownViewer(componentId, Model.of(blob.getText().getContent()), null) { + + private static final long serialVersionUID = 1L; + + @Override + protected BlobRenderContext getRenderContext() { + return new BlobRenderContext() { + + private static final long serialVersionUID = 1L; + + @Override + public Project getProject() { + return getProjectManager().load(projectId); + } + + @Override + public BlobIdent getBlobIdent() { + return blobIdent; + } + + @Override + public String getPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public String getCoverageReport() { + throw new UnsupportedOperationException(); + } + + @Override + public String getProblemReport() { + throw new UnsupportedOperationException(); + } + + @Override + public void onPosition(AjaxRequestTarget target, String position) { + throw new UnsupportedOperationException(); + } + + @Override + public String getPositionUrl(String position) { + throw new UnsupportedOperationException(); + } + + @Override + public String getDirectory() { + if (blobIdent.path.contains("/")) + return StringUtils.substringBeforeLast(blobIdent.path, "/"); + else + return null; + } + + @Override + public String getDirectoryUrl() { + BlobIdent blobIdent = new BlobIdent("main", getDirectory(), FileMode.TREE.getBits()); + ProjectBlobPage.State state = new ProjectBlobPage.State(blobIdent); + return urlFor(ProjectBlobPage.class, ProjectBlobPage.paramsOf(getProject(), state)).toString(); + } + + @Override + public String getRootDirectoryUrl() { + throw new UnsupportedOperationException(); + } + + @Override + public Mode getMode() { + return Mode.VIEW; + } + + @Override + public boolean isViewPlain() { + throw new UnsupportedOperationException(); + } + + @Override + public String getUrlBeforeEdit() { + throw new UnsupportedOperationException(); + } + + @Override + public String getUrlAfterEdit() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isOnBranch() { + throw new UnsupportedOperationException(); + } + + @Override + public String getRefName() { + throw new UnsupportedOperationException(); + } + + @Override + public void pushState(AjaxRequestTarget target, BlobIdent blobIdent, String position) { + throw new UnsupportedOperationException(); + } + + @Override + public void replaceState(AjaxRequestTarget target, BlobIdent blobIdent, String position) { + throw new UnsupportedOperationException(); + } + + @Override + public void onSelect(AjaxRequestTarget target, BlobIdent blobIdent, String position) { + throw new UnsupportedOperationException(); + } + + @Override + public void onSearchComplete(AjaxRequestTarget target, List hits) { + throw new UnsupportedOperationException(); + } + + @Override + public void onModeChange(AjaxRequestTarget target, Mode mode, String newPath) { + throw new UnsupportedOperationException(); + } + + @Override + public void onModeChange(AjaxRequestTarget target, Mode mode, boolean viewPlain, String newPath) { + throw new UnsupportedOperationException(); + } + + @Override + public void onCommitted(AjaxRequestTarget target, ObjectId commitId) { + throw new UnsupportedOperationException(); + } + + @Override + public void onCommentOpened(AjaxRequestTarget target, CodeComment comment, PlanarRange range) { + throw new UnsupportedOperationException(); + } + + @Override + public void onCommentClosed(AjaxRequestTarget target) { + throw new UnsupportedOperationException(); + } + + @Override + public void onAddComment(AjaxRequestTarget target, PlanarRange range) { + throw new UnsupportedOperationException(); + } + + @Override + public ObjectId uploadFiles(Collection uploads, String directory, + String commitMessage) { + throw new UnsupportedOperationException(); + } + + @Override + public CodeComment getOpenComment() { + throw new UnsupportedOperationException(); + } + + @Override + public RevCommit getCommit() { + throw new UnsupportedOperationException(); + } + + @Override + public String getNewPath() { + throw new UnsupportedOperationException(); + } + + @Override + public String getInitialNewPath() { + throw new UnsupportedOperationException(); + } + + @Override + public String appendRaw(String url) { + return ProjectBlobPage.doAppendRaw(url); + } + + @Override + public PullRequest getPullRequest() { + throw new UnsupportedOperationException(); + } + + }; + } + + }; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownWidget.java new file mode 100644 index 0000000000..541473234b --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MarkdownWidget.java @@ -0,0 +1,45 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.Markdown; +import io.onedev.server.model.support.Widget; +import io.onedev.server.web.component.markdown.MarkdownViewer; +import org.apache.wicket.Component; +import org.apache.wicket.model.Model; + +import javax.validation.constraints.NotEmpty; + +@Editable(name="Markdown", order=10000) +public class MarkdownWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String markdown; + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @Editable(order=100) + @Markdown + @NotEmpty + public String getMarkdown() { + return markdown; + } + + public void setMarkdown(String markdown) { + this.markdown = markdown; + } + + @Override + protected Component doRender(String componentId) { + return new MarkdownViewer(componentId, Model.of(markdown), null); + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MilestoneListWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MilestoneListWidget.java new file mode 100644 index 0000000000..c9f173c9e5 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/MilestoneListWidget.java @@ -0,0 +1,86 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.OneDev; +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectChoice; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Project; +import io.onedev.server.model.support.Widget; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.security.permission.AccessProject; +import io.onedev.server.util.MilestoneSort; +import io.onedev.server.web.component.milestone.list.MilestoneListPanel; +import org.apache.wicket.Component; +import org.apache.wicket.model.LoadableDetachableModel; + +import javax.validation.constraints.NotEmpty; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Editable(order=210, name="Milestone List") +public class MilestoneListWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + @Editable(order=100) + @ProjectChoice("getPermittedProjects") + @NotEmpty + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + @Override + public int getDefaultWidth() { + return 36; + } + + @Override + public int getDefaultHeight() { + return 16; + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new AccessProject())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Override + protected Component doRender(String componentId) { + Long projectId; + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else + projectId = project.getId(); + + if (SecurityUtils.canAccess(project)) { + return new MilestoneListPanel(componentId, new LoadableDetachableModel() { + + private static final long serialVersionUID = 1L; + + @Override + protected Project load() { + return getProjectManager().load(projectId); + } + + }, false, MilestoneSort.CLOSEST_DUE_DATE, null); + } else { + throw new ExplicitException("Permission denied"); + } + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/ProjectListWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/ProjectListWidget.java new file mode 100644 index 0000000000..ac74147ad0 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/ProjectListWidget.java @@ -0,0 +1,52 @@ +package io.onedev.server.ee.dashboard.widgets; + +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectQuery; +import org.apache.wicket.Component; +import org.apache.wicket.model.Model; + +import io.onedev.server.model.support.Widget; +import io.onedev.server.web.component.project.list.ProjectListPanel; + +@Editable(name="Project List", order=100) +public class ProjectListWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String baseQuery; + + @Editable(order=100, placeholder="All accessible", description="Optionally specify a base query for the list") + @ProjectQuery + public String getBaseQuery() { + return baseQuery; + } + + public void setBaseQuery(String baseQuery) { + this.baseQuery = baseQuery; + } + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @Override + protected Component doRender(String componentId) { + return new ProjectListPanel(componentId, Model.of((String)null), 0) { + + private static final long serialVersionUID = 1L; + + @Override + protected io.onedev.server.search.entity.project.ProjectQuery getBaseQuery() { + return io.onedev.server.search.entity.project.ProjectQuery.parse(baseQuery); + } + + }; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/PullRequestListWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/PullRequestListWidget.java new file mode 100644 index 0000000000..0bae179158 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/PullRequestListWidget.java @@ -0,0 +1,107 @@ +package io.onedev.server.ee.dashboard.widgets; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectChoice; +import io.onedev.server.annotation.PullRequestQuery; +import org.apache.wicket.Component; +import org.apache.wicket.model.Model; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.OneDev; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Project; +import io.onedev.server.model.support.Widget; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.security.permission.ReadCode; +import io.onedev.server.web.component.pullrequest.list.PullRequestListPanel; + +@Editable(name="Pull Request List", order=300) +public class PullRequestListWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + private String baseQuery; + + @Editable(order=100, name="Project", placeholder="All accessible", description="Optionally specify project " + + "to show issues of. Leave empty to show issues of all accessible projects") + @ProjectChoice("getPermittedProjects") + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new ReadCode())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Editable(order=200, placeholder="All pull requests", description="Optionally specify base query of the list") + @PullRequestQuery + public String getBaseQuery() { + return baseQuery; + } + + public void setBaseQuery(String baseQuery) { + this.baseQuery = baseQuery; + } + + @Override + public int getDefaultWidth() { + return 30; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + @Override + protected Component doRender(String componentId) { + Long projectId; + if (projectPath != null) { + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else if (!SecurityUtils.canReadCode(project)) + throw new ExplicitException("Permission denied"); + else + projectId = project.getId(); + } else { + projectId = null; + } + return new PullRequestListPanel(componentId, Model.of((String)null)) { + + private static final long serialVersionUID = 1L; + + @Override + protected Project getProject() { + if (projectId != null) + return getProjectManager().load(projectId); + else + return null; + } + + @Override + protected io.onedev.server.search.entity.pullrequest.PullRequestQuery getBaseQuery() { + return io.onedev.server.search.entity.pullrequest.PullRequestQuery.parse(getProject(), baseQuery, true); + } + + }; + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/WidgetGroup.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/WidgetGroup.java new file mode 100644 index 0000000000..82a90cd0b7 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/WidgetGroup.java @@ -0,0 +1,7 @@ +package io.onedev.server.ee.dashboard.widgets; + +public class WidgetGroup { + + public static final String REPORTS = "Reports"; + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewCssResourceReference.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewCssResourceReference.java new file mode 100644 index 0000000000..18dcfb3a75 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewCssResourceReference.java @@ -0,0 +1,13 @@ +package io.onedev.server.ee.dashboard.widgets.projectoverview; + +import io.onedev.server.web.page.base.BaseDependentCssResourceReference; + +public class ProjectOverviewCssResourceReference extends BaseDependentCssResourceReference { + + private static final long serialVersionUID = 1L; + + public ProjectOverviewCssResourceReference() { + super(ProjectOverviewCssResourceReference.class, "project-overview.css"); + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.html b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.html new file mode 100644 index 0000000000..02e7be49a7 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.html @@ -0,0 +1,9 @@ + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.java new file mode 100644 index 0000000000..cdd5869dc5 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewPanel.java @@ -0,0 +1,110 @@ +package io.onedev.server.ee.dashboard.widgets.projectoverview; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.panel.GenericPanel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.Model; + +import com.google.common.collect.Lists; + +import io.onedev.server.OneDev; +import io.onedev.server.entitymanager.BuildManager; +import io.onedev.server.entitymanager.IssueManager; +import io.onedev.server.entitymanager.PullRequestManager; +import io.onedev.server.model.Build; +import io.onedev.server.model.Project; +import io.onedev.server.model.PullRequest; +import io.onedev.server.model.PullRequest.Status; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.util.ProjectBuildStats; +import io.onedev.server.util.ProjectIssueStats; +import io.onedev.server.util.ProjectPullRequestStats; +import io.onedev.server.web.component.markdown.MarkdownViewer; +import io.onedev.server.web.component.project.stats.build.BuildStatsPanel; +import io.onedev.server.web.component.project.stats.code.CodeStatsPanel; +import io.onedev.server.web.component.project.stats.issue.IssueStatsPanel; +import io.onedev.server.web.component.project.stats.pullrequest.PullRequestStatsPanel; + +@SuppressWarnings("serial") +public class ProjectOverviewPanel extends GenericPanel { + + public ProjectOverviewPanel(String id, IModel model) { + super(id, model); + } + + @Override + protected void onInitialize() { + super.onInitialize(); + + if (getProject().getDescription() != null) + add(new MarkdownViewer("description", Model.of(getProject().getDescription()), null)); + else + add(new WebMarkupContainer("description").setVisible(false)); + + if (getProject().isCodeManagement() && SecurityUtils.canReadCode(getProject())) { + add(new CodeStatsPanel("codeStats", getModel())); + add(new PullRequestStatsPanel("pullRequestStats", getModel(), new LoadableDetachableModel>() { + + @Override + protected Map load() { + Map statusCount = new LinkedHashMap<>(); + for (ProjectPullRequestStats stats: OneDev.getInstance(PullRequestManager.class).queryStats(Lists.newArrayList(getProject()))) { + statusCount.put(stats.getPullRequestStatus(), stats.getStatusCount()); + } + return statusCount; + } + + })); + } else { + add(new WebMarkupContainer("codeStats").setVisible(false)); + add(new WebMarkupContainer("pullRequestStats").setVisible(false)); + } + + if (getProject().isIssueManagement()) { + add(new IssueStatsPanel("issueStats", getModel(), new LoadableDetachableModel>() { + + @Override + protected Map load() { + Map stateCount = new LinkedHashMap<>(); + for (ProjectIssueStats stats: OneDev.getInstance(IssueManager.class).queryStats(Lists.newArrayList(getProject()))) { + stateCount.put(stats.getStateOrdinal(), stats.getStateCount()); + } + return stateCount; + } + + })); + } else { + add(new WebMarkupContainer("issueStats").setVisible(false)); + } + + add(new BuildStatsPanel("buildStats", getModel(), new LoadableDetachableModel>() { + + @Override + protected Map load() { + Map statusCount = new LinkedHashMap<>(); + for (ProjectBuildStats stats: OneDev.getInstance(BuildManager.class).queryStats(Lists.newArrayList(getProject()))) { + statusCount.put(stats.getBuildStatus(), stats.getStatusCount()); + } + return statusCount; + } + + })); + } + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + response.render(CssHeaderItem.forReference(new ProjectOverviewCssResourceReference())); + } + + private Project getProject() { + return getModelObject(); + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewWidget.java b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewWidget.java new file mode 100644 index 0000000000..d4da5db6ee --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/ProjectOverviewWidget.java @@ -0,0 +1,86 @@ +package io.onedev.server.ee.dashboard.widgets.projectoverview; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.validation.constraints.NotEmpty; + +import io.onedev.server.annotation.Editable; +import io.onedev.server.annotation.ProjectChoice; +import org.apache.wicket.Component; +import org.apache.wicket.model.LoadableDetachableModel; + +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.OneDev; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Project; +import io.onedev.server.model.support.Widget; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.security.permission.AccessProject; + +@Editable(order=110, name="Project Overview") +public class ProjectOverviewWidget extends Widget { + + private static final long serialVersionUID = 1L; + + private String projectPath; + + @Editable(order=100) + @ProjectChoice("getPermittedProjects") + @NotEmpty + public String getProjectPath() { + return projectPath; + } + + public void setProjectPath(String projectPath) { + this.projectPath = projectPath; + } + + @Override + public int getDefaultWidth() { + return 16; + } + + @Override + public int getDefaultHeight() { + return 24; + } + + private static ProjectManager getProjectManager() { + return OneDev.getInstance(ProjectManager.class); + } + + @SuppressWarnings("unused") + private static List getPermittedProjects() { + List projects = new ArrayList<>(getProjectManager().getPermittedProjects(new AccessProject())); + Collections.sort(projects, getProjectManager().cloneCache().comparingPath()); + return projects; + } + + @Override + protected Component doRender(String componentId) { + Long projectId; + Project project = getProjectManager().findByPath(projectPath); + if (project == null) + throw new ExplicitException("Project not found: " + projectPath); + else + projectId = project.getId(); + + if (SecurityUtils.canAccess(project)) { + return new ProjectOverviewPanel(componentId, new LoadableDetachableModel() { + + private static final long serialVersionUID = 1L; + + @Override + protected Project load() { + return getProjectManager().load(projectId); + } + + }); + } else { + throw new ExplicitException("Permission denied"); + } + } + +} diff --git a/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/project-overview.css b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/project-overview.css new file mode 100644 index 0000000000..c268f5e697 --- /dev/null +++ b/server-ee/server-ee-dashboard/src/main/java/io/onedev/server/ee/dashboard/widgets/projectoverview/project-overview.css @@ -0,0 +1,6 @@ +.project-overview>div { + margin-bottom: 1rem; +} +.project-overview>div:last-child { + margin-bottom: 0; +} diff --git a/server-ee/server-ee-storage/pom.xml b/server-ee/server-ee-storage/pom.xml new file mode 100644 index 0000000000..65ff12f487 --- /dev/null +++ b/server-ee/server-ee-storage/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + server-ee-storage + + io.onedev + server-ee + 8.1.0 + + + io.onedev.server.ee.storage.StorageModule + + diff --git a/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/EEStorageManager.java b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/EEStorageManager.java new file mode 100644 index 0000000000..6a8325ca41 --- /dev/null +++ b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/EEStorageManager.java @@ -0,0 +1,145 @@ +package io.onedev.server.ee.storage; + +import io.onedev.commons.bootstrap.Bootstrap; +import io.onedev.commons.loader.ManagedSerializedForm; +import io.onedev.commons.utils.FileUtils; +import io.onedev.server.cluster.ClusterManager; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.entitymanager.SettingManager; +import io.onedev.server.event.Listen; +import io.onedev.server.event.entity.EntityRemoved; +import io.onedev.server.model.Build; +import io.onedev.server.model.Project; +import io.onedev.server.persistence.TransactionManager; +import io.onedev.server.storage.StorageManager; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.io.ObjectStreamException; +import java.io.Serializable; +import java.nio.file.Files; + +@Singleton +public class EEStorageManager implements StorageManager, Serializable { + + private final SettingManager settingManager; + + private final TransactionManager transactionManager; + + private final ClusterManager clusterManager; + + private final ProjectManager projectManager; + + @Inject + public EEStorageManager(SettingManager settingManager, TransactionManager transactionManager, + ClusterManager clusterManager, ProjectManager projectManager) { + this.settingManager = settingManager; + this.transactionManager = transactionManager; + this.clusterManager = clusterManager; + this.projectManager = projectManager; + } + + @Nullable + private File getLfsStorageDir(Long projectId) { + StorageSetting storageSetting = settingManager.getContributedSetting(StorageSetting.class); + if (storageSetting != null && storageSetting.getLfsStore() != null) { + File lfsStore = new File(storageSetting.getLfsStore()); + String projectIdString = String.valueOf(projectId); + if (lfsStore.isAbsolute()) + return new File(lfsStore, projectIdString); + else + return new File(new File(Bootstrap.getSiteDir(), storageSetting.getLfsStore()), projectIdString); + } else { + return null; + } + } + + @Nullable + private File getArtifactsStorageDir(Long projectId, @Nullable Long buildNumber) { + StorageSetting storageSetting = settingManager.getContributedSetting(StorageSetting.class); + if (storageSetting != null && storageSetting.getArtifactStore() != null) { + File artifactsStore = new File(storageSetting.getArtifactStore()); + String subpath = String.valueOf(projectId); + if (buildNumber != null) + subpath += "/" + Build.getStoragePath(buildNumber); + if (artifactsStore.isAbsolute()) + return new File(artifactsStore, subpath); + else + return new File(new File(Bootstrap.getSiteDir(), storageSetting.getArtifactStore()), subpath); + } else { + return null; + } + } + + @Override + public void initLfsDir(Long projectId) { + File gitDir = projectManager.getGitDir(projectId); + File lfsStorageDir = getLfsStorageDir(projectId); + var lfsDir = new File(gitDir, "lfs"); + if (lfsStorageDir != null && !lfsDir.exists()) { + FileUtils.createDir(lfsStorageDir); + try { + Files.createSymbolicLink(lfsDir.toPath(), lfsStorageDir.toPath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void initArtifactsDir(Long projectId, Long buildNumber) { + File artifactsStorageDir = getArtifactsStorageDir(projectId, buildNumber); + File artifactsDir = Build.getArtifactsDir(projectId, buildNumber); + if (artifactsStorageDir != null && !artifactsDir.exists()) { + FileUtils.createDir(artifactsStorageDir); + try { + Files.createSymbolicLink( + artifactsDir.toPath(), + artifactsStorageDir.toPath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + @Listen + public void on(EntityRemoved event) { + if (event.getEntity() instanceof Build) { + Build build = (Build) event.getEntity(); + Long projectId = build.getProject().getId(); + Long buildNumber = build.getNumber(); + String activeServer = projectManager.getActiveServer(projectId, false); + if (activeServer != null) { + transactionManager.runAfterCommit(() -> clusterManager.submitToServer(activeServer, () -> { + File artifactsStorageDir = getArtifactsStorageDir(projectId, buildNumber); + if (artifactsStorageDir != null && artifactsStorageDir.exists()) + FileUtils.deleteDir(artifactsStorageDir); + return null; + })); + } + } else if (event.getEntity() instanceof Project) { + Project project = (Project) event.getEntity(); + Long projectId = project.getId(); + var activeServer = projectManager.getActiveServer(projectId, false); + if (activeServer != null) { + transactionManager.runAfterCommit(() -> clusterManager.submitToServer(activeServer, () -> { + File lfsStorageDir = getLfsStorageDir(projectId); + if (lfsStorageDir != null && lfsStorageDir.exists()) + FileUtils.deleteDir(lfsStorageDir); + File artifactsStorageDir = getArtifactsStorageDir(projectId, null); + if (artifactsStorageDir != null && artifactsStorageDir.exists()) + FileUtils.deleteDir(artifactsStorageDir); + return null; + })); + } + } + } + + public Object writeReplace() throws ObjectStreamException { + return new ManagedSerializedForm(StorageManager.class); + } + +} diff --git a/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageModule.java b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageModule.java new file mode 100644 index 0000000000..c3156c8bee --- /dev/null +++ b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageModule.java @@ -0,0 +1,25 @@ +package io.onedev.server.ee.storage; + +import io.onedev.commons.loader.AbstractPluginModule; +import io.onedev.server.storage.StorageManager; +import io.onedev.server.web.page.layout.AdministrationSettingContribution; + +import static com.beust.jcommander.internal.Lists.newArrayList; + +/** + * NOTE: Do not forget to rename moduleClass property defined in the pom if you've renamed this class. + * + */ +public class StorageModule extends AbstractPluginModule { + + @Override + protected void configure() { + super.configure(); + + // put your guice bindings here + contribute(AdministrationSettingContribution.class, () -> newArrayList(StorageSetting.class)); + + bind(StorageManager.class).to(EEStorageManager.class); + } + +} diff --git a/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageSetting.java b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageSetting.java new file mode 100644 index 0000000000..13a464e86a --- /dev/null +++ b/server-ee/server-ee-storage/src/main/java/io/onedev/server/ee/storage/StorageSetting.java @@ -0,0 +1,33 @@ +package io.onedev.server.ee.storage; + +import io.onedev.server.annotation.Editable; +import io.onedev.server.web.page.layout.ContributedAdministrationSetting; + +@Editable +public class StorageSetting implements ContributedAdministrationSetting { + + private static final long serialVersionUID = 1L; + + private String lfsStore; + + private String artifactStore; + + @Editable(order=100) + public String getLfsStore() { + return lfsStore; + } + + public void setLfsStore(String lfsStore) { + this.lfsStore = lfsStore; + } + + @Editable(order=200) + public String getArtifactStore() { + return artifactStore; + } + + public void setArtifactStore(String artifactStore) { + this.artifactStore = artifactStore; + } + +} diff --git a/server-ee/server-ee-terminal/pom.xml b/server-ee/server-ee-terminal/pom.xml new file mode 100644 index 0000000000..cf833c94bc --- /dev/null +++ b/server-ee/server-ee-terminal/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + server-ee-terminal + + io.onedev + server-ee + 8.1.0 + + + io.onedev.server.ee.terminal.TerminalModule + + diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.html b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.html new file mode 100644 index 0000000000..a8a569aea1 --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.html @@ -0,0 +1,3 @@ + +
+
\ No newline at end of file diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.java b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.java new file mode 100644 index 0000000000..acc4dd8e8d --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalPage.java @@ -0,0 +1,146 @@ +package io.onedev.server.ee.terminal; + +import java.io.IOException; + +import org.apache.wicket.Application; +import org.apache.wicket.Session; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.head.OnDomReadyHeaderItem; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.protocol.ws.api.IWebSocketConnection; +import org.apache.wicket.protocol.ws.api.WebSocketBehavior; +import org.apache.wicket.protocol.ws.api.WebSocketRequestHandler; +import org.apache.wicket.protocol.ws.api.message.ClosedMessage; +import org.apache.wicket.protocol.ws.api.message.ConnectedMessage; +import org.apache.wicket.protocol.ws.api.message.TextMessage; +import org.apache.wicket.protocol.ws.api.registry.PageIdKey; +import org.apache.wicket.protocol.ws.api.registry.SimpleWebSocketConnectionRegistry; +import org.apache.wicket.request.mapper.parameter.PageParameters; + +import io.onedev.commons.utils.StringUtils; +import io.onedev.server.OneDev; +import io.onedev.server.entitymanager.BuildManager; +import io.onedev.server.entitymanager.ProjectManager; +import io.onedev.server.model.Build; +import io.onedev.server.model.Project; +import io.onedev.server.security.SecurityUtils; +import io.onedev.server.terminal.MessageTypes; +import io.onedev.server.terminal.TerminalManager; +import io.onedev.server.web.page.base.BasePage; + +@SuppressWarnings("serial") +public class BuildTerminalPage extends BasePage { + + private static final String PARAM_PROJECT = "project"; + + private static final String PARAM_BUILD = "build"; + + private final IModel buildModel; + + public BuildTerminalPage(PageParameters params) { + super(params); + + Long projectId = params.get(PARAM_PROJECT).toLongObject(); + + Long buildNumber = params.get(PARAM_BUILD).toLongObject(); + buildModel = new LoadableDetachableModel() { + + @Override + protected Build load() { + Project project = OneDev.getInstance(ProjectManager.class).load(projectId); + return OneDev.getInstance(BuildManager.class).find(project, buildNumber); + } + + }; + } + + @Override + protected void onInitialize() { + super.onInitialize(); + + add(new WebSocketBehavior() { + + private TerminalManager getTerminalManager() { + return OneDev.getInstance(TerminalManager.class); + } + + @Override + protected void onConnect(ConnectedMessage message) { + IWebSocketConnection connection = new SimpleWebSocketConnectionRegistry().getConnection( + message.getApplication(), message.getSessionId(), message.getKey()); + if (connection != null) { + try { + connection.sendMessage(MessageTypes.TERMINAL_OPEN.name()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + @Override + protected void onClose(ClosedMessage message) { + IWebSocketConnection connection = new SimpleWebSocketConnectionRegistry().getConnection( + message.getApplication(), message.getSessionId(), message.getKey()); + if (connection != null) + getTerminalManager().onClose(connection); + } + + @Override + protected void onMessage(WebSocketRequestHandler handler, TextMessage message) { + IWebSocketConnection connection = new SimpleWebSocketConnectionRegistry().getConnection( + Application.get(), Session.get().getId(), new PageIdKey(getPageId())); + if (connection != null) { + if (message.getText().equals(MessageTypes.TERMINAL_READY.name())) { + getTerminalManager().onOpen(connection, getBuild()); + } else if (message.getText().startsWith(MessageTypes.TERMINAL_INPUT.name())) { + String input = message.getText().substring(MessageTypes.TERMINAL_INPUT.name().length()+1); + getTerminalManager().onInput(connection, input); + } else if (message.getText().startsWith(MessageTypes.TERMINAL_RESIZE.name())) { + String input = message.getText().substring(MessageTypes.TERMINAL_RESIZE.name().length()+1); + int rows = Integer.parseInt(StringUtils.substringBefore(input, ":")); + int cols = Integer.parseInt(StringUtils.substringAfter(input, ":")); + getTerminalManager().onResize(connection, rows, cols); + } + } + } + + }); + } + + @Override + protected boolean isPermitted() { + return SecurityUtils.canManage(getBuild().getProject()); + } + + private Build getBuild() { + return buildModel.getObject(); + } + + @Override + protected void onDetach() { + buildModel.detach(); + super.onDetach(); + } + + @Override + protected String getPageTitle() { + return "Web Terminal"; + } + + @Override + public void renderHead(IHeaderResponse response) { + super.renderHead(response); + response.render(JavaScriptHeaderItem.forReference(new BuildTerminalResourceReference())); + response.render(OnDomReadyHeaderItem.forScript("onedev.server.buildTerminal.onDomReady();")); + } + + public static PageParameters paramsOf(Build build) { + PageParameters params = new PageParameters(); + params.add(PARAM_PROJECT, build.getProject().getId()); + params.add(PARAM_BUILD, build.getNumber()); + return params; + } + +} diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalResourceReference.java b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalResourceReference.java new file mode 100644 index 0000000000..1d4f7f8c46 --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/BuildTerminalResourceReference.java @@ -0,0 +1,26 @@ +package io.onedev.server.ee.terminal; + +import java.util.List; + +import org.apache.wicket.markup.head.HeaderItem; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; + +import io.onedev.server.web.asset.xterm.XtermResourceReference; +import io.onedev.server.web.page.base.BaseDependentResourceReference; + +public class BuildTerminalResourceReference extends BaseDependentResourceReference { + + private static final long serialVersionUID = 1L; + + public BuildTerminalResourceReference() { + super(BuildTerminalResourceReference.class, "build-terminal.js"); + } + + @Override + public List getDependencies() { + List dependencies = super.getDependencies(); + dependencies.add(JavaScriptHeaderItem.forReference(new XtermResourceReference())); + return dependencies; + } + +} diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/EETerminalManager.java b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/EETerminalManager.java new file mode 100644 index 0000000000..aebb448094 --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/EETerminalManager.java @@ -0,0 +1,259 @@ +package io.onedev.server.ee.terminal; + +import io.onedev.commons.loader.ManagedSerializedForm; +import io.onedev.commons.utils.ExceptionUtils; +import io.onedev.commons.utils.ExplicitException; +import io.onedev.server.cluster.ClusterManager; +import io.onedev.server.cluster.ClusterTask; +import io.onedev.server.event.Listen; +import io.onedev.server.event.project.build.BuildEvent; +import io.onedev.server.event.system.SystemStarted; +import io.onedev.server.event.system.SystemStopping; +import io.onedev.server.job.JobManager; +import io.onedev.server.model.Build; +import io.onedev.server.persistence.SessionManager; +import io.onedev.server.persistence.annotation.Sessional; +import io.onedev.server.terminal.MessageTypes; +import io.onedev.server.terminal.Terminal; +import io.onedev.server.terminal.TerminalManager; +import io.onedev.server.terminal.WebShell; +import io.onedev.server.util.schedule.SchedulableTask; +import io.onedev.server.util.schedule.TaskScheduler; +import org.apache.wicket.protocol.ws.api.IWebSocketConnection; +import org.apache.wicket.request.cycle.RequestCycle; +import org.quartz.ScheduleBuilder; +import org.quartz.SimpleScheduleBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.ObjectStreamException; +import java.io.Serializable; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Singleton +public class EETerminalManager implements TerminalManager, SchedulableTask, Serializable { + + private static final Logger logger = LoggerFactory.getLogger(EETerminalManager.class); + + private final JobManager jobManager; + + final ClusterManager clusterManager; + + private final TaskScheduler taskScheduler; + + private final SessionManager sessionManager; + + private final Map shells = new ConcurrentHashMap<>(); + + private volatile String taskId; + + @Inject + public EETerminalManager(JobManager jobManager, TaskScheduler taskScheduler, ClusterManager clusterManager, + SessionManager sessionManager) { + this.jobManager = jobManager; + this.taskScheduler = taskScheduler; + this.clusterManager = clusterManager; + this.sessionManager = sessionManager; + } + + public Object writeReplace() throws ObjectStreamException { + return new ManagedSerializedForm(TerminalManager.class); + } + + @Override + public void onOpen(IWebSocketConnection connection, Build build) { + try { + Long buildId = build.getId(); + String sessionId = UUID.randomUUID().toString(); + Terminal terminal = new ServerTerminal(sessionId, clusterManager.getLocalServerAddress()); + shells.put(connection, jobManager.openShell(buildId, terminal)); + } catch (Throwable t) { + ExplicitException explicitException = ExceptionUtils.find(t, ExplicitException.class); + if (explicitException != null) { + sendError(connection, explicitException.getMessage()); + } else { + logger.error("Error openning shell", t); + sendError(connection, "Error opening shell, check server log for details"); + } + } + } + + @Override + public void onClose(IWebSocketConnection connection) { + var shell = shells.remove(connection); + if (shell != null) + shell.exit(); + } + + @Override + public void onInput(IWebSocketConnection connection, String input) { + var shell = shells.get(connection); + if (shell != null) + shell.sendInput(input); + } + + @Listen + public void on(SystemStarted event) { + taskId = taskScheduler.schedule(this); + } + + @Listen + public void on(SystemStopping event) { + if (taskId != null) + taskScheduler.unschedule(taskId); + } + + @Sessional + @Listen + public void on(BuildEvent event) { + if (event.getBuild().isFinished()) { + clusterManager.submitToAllServers(new ClusterTask() { + + private static final long serialVersionUID = 1L; + + @Override + public Void call() throws Exception { + sessionManager.run(new Runnable() { + + @Override + public void run() { + for (var it = shells.entrySet().iterator(); it.hasNext();) { + var entry = it.next(); + WebShell shell = entry.getValue(); + if (shell.getBuildId().equals(event.getBuild().getId())) { + sendError(entry.getKey(), "Shell exited"); + shell.exit(); + it.remove(); + } + } + } + + }); + return null; + } + + }); + } + } + + @Override + public void execute() { + for (Iterator> it = shells.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + WebShell shell = entry.getValue(); + if (!entry.getKey().isOpen()) { + shell.exit(); + it.remove(); + } + } + } + + @Override + public ScheduleBuilder getScheduleBuilder() { + return SimpleScheduleBuilder.repeatMinutelyForever(); + } + + @Override + public void onResize(IWebSocketConnection connection, int rows, int cols) { + WebShell shell = shells.get(connection); + if (shell != null) + shell.resize(rows, cols); + } + + @Override + public boolean isTerminalSupported() { + return true; + } + + @Override + public String getTerminalUrl(Build build) { + return RequestCycle.get().urlFor(BuildTerminalPage.class, BuildTerminalPage.paramsOf(build)).toString(); + } + + private void sendOutput(IWebSocketConnection connection, String output) { + try { + connection.sendMessage(MessageTypes.TERMINAL_OUTPUT + ":" + output); + } catch (Exception e) { + } + } + + private void sendError(IWebSocketConnection connection, String error) { + try { + connection.sendMessage(MessageTypes.TERMINAL_OUTPUT + ":\r\n\033[31m" + error + "\033[0m"); + } catch (Exception e) { + } + } + + private void close(IWebSocketConnection connection) { + try { + connection.sendMessage(MessageTypes.TERMINAL_CLOSE.name()); + } catch (Exception e) { + } + } + + private class ServerTerminal implements Terminal, Serializable { + + private static final long serialVersionUID = 1L; + + private final String sessionId; + + private final String terminalServer; + + public ServerTerminal(String sessionId, String terminalServer) { + this.sessionId = sessionId; + this.terminalServer = terminalServer; + } + + @Nullable + private IWebSocketConnection getConnection() { + for (var entry: shells.entrySet()) { + if (entry.getValue().getSessionId().equals(sessionId)) + return entry.getKey(); + } + return null; + } + + @Override + public void sendOutput(String output) { + clusterManager.submitToServer(terminalServer, () -> { + IWebSocketConnection connection = getConnection(); + if (connection != null) + EETerminalManager.this.sendOutput(connection, output); + return null; + }); + } + + @Override + public void sendError(String error) { + clusterManager.submitToServer(terminalServer, () -> { + IWebSocketConnection connection = getConnection(); + if (connection != null) + EETerminalManager.this.sendError(connection, error); + return null; + }); + } + + @Override + public void close() { + clusterManager.submitToServer(terminalServer, () -> { + IWebSocketConnection connection = getConnection(); + if (connection != null) + EETerminalManager.this.close(connection); + return null; + }); + } + + @Override + public String getSessionId() { + return sessionId; + } + + } + +} diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalModule.java b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalModule.java new file mode 100644 index 0000000000..e3119f54d2 --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalModule.java @@ -0,0 +1,32 @@ +package io.onedev.server.ee.terminal; + +import org.apache.wicket.protocol.http.WebApplication; + +import io.onedev.commons.loader.AbstractPluginModule; +import io.onedev.server.terminal.TerminalManager; +import io.onedev.server.web.WebApplicationConfigurator; +import io.onedev.server.web.mapper.BasePageMapper; + +/** + * NOTE: Do not forget to rename moduleClass property defined in the pom if you've renamed this class. + * + */ +public class TerminalModule extends AbstractPluginModule { + + @Override + protected void configure() { + super.configure(); + + // put your guice bindings here + bind(TerminalManager.class).to(EETerminalManager.class); + contribute(WebApplicationConfigurator.class, new WebApplicationConfigurator() { + + @Override + public void configure(WebApplication application) { + application.mount(new BasePageMapper("projects/${project}/builds/${build}/terminal", BuildTerminalPage.class)); + } + + }); + } + +} diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalSession.java b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalSession.java new file mode 100644 index 0000000000..3e66206f11 --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/TerminalSession.java @@ -0,0 +1,24 @@ +package io.onedev.server.ee.terminal; + +import io.onedev.server.terminal.Shell; + +public class TerminalSession { + + private final Long buildId; + + private final Shell shell; + + public TerminalSession(Long buildId, Shell shellSession) { + this.buildId = buildId; + this.shell = shellSession; + } + + public Long getBuildId() { + return buildId; + } + + public Shell getShell() { + return shell; + } + +} diff --git a/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/build-terminal.js b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/build-terminal.js new file mode 100644 index 0000000000..c9fdb336eb --- /dev/null +++ b/server-ee/server-ee-terminal/src/main/java/io/onedev/server/ee/terminal/build-terminal.js @@ -0,0 +1,37 @@ +onedev.server.buildTerminal = { + onDomReady: function() { + var hasOutput = false; + var $terminal = $(".terminal"); + var xterm = new Terminal(); + var fitAddon = new window.FitAddon.FitAddon(); + xterm.loadAddon(fitAddon); + + Wicket.Event.subscribe("/websocket/message", function(jqEvent, message) { + if (message == "TERMINAL_OPEN") { + xterm.open($(".terminal")[0]); + xterm.onData(function(data) { + Wicket.WebSocket.send("TERMINAL_INPUT:" + data); + }); + xterm.onResize(function(size) { + Wicket.WebSocket.send("TERMINAL_RESIZE:" + size.rows + ":" + size.cols); + }); + Wicket.WebSocket.send("TERMINAL_READY"); + + $terminal.on("resized", function() { + if (hasOutput) + fitAddon.fit(); + }); + } else if (message == "TERMINAL_CLOSE") { + window.close(); + } else if (message.startsWith("TERMINAL_OUTPUT:")) { + if (!hasOutput) { + // fit on first prompt to make sure underlying shell + // receives the resize command + hasOutput = true; + fitAddon.fit(); + } + xterm.write(message.substring("TERMINAL_OUTPUT:".length)); + } + }); + } +} \ No newline at end of file diff --git a/server-product/pom.xml b/server-product/pom.xml index 9e957365f7..0219025224 100644 --- a/server-product/pom.xml +++ b/server-product/pom.xml @@ -160,6 +160,26 @@ server-plugin-notification-discord ${project.version} + + io.onedev + server-ee-dashboard + ${project.version} + + + io.onedev + server-ee-storage + ${project.version} + + + io.onedev + server-ee-terminal + ${project.version} + + + io.onedev + server-ee-clustering + ${project.version} +